diff --git a/bin/lib/cli-utils.mjs b/bin/lib/cli-utils.js similarity index 88% rename from bin/lib/cli-utils.mjs rename to bin/lib/cli-utils.js index 8946e0c5c..676fc99f6 100644 --- a/bin/lib/cli-utils.mjs +++ b/bin/lib/cli-utils.js @@ -1,54 +1,54 @@ -import fs from 'fs-extra' -import { red, cyan, bold } from 'colorette' -import { URL } from 'url' -import LDP from '../../lib/ldp.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' - -export function getAccountManager (config, options = {}) { - const ldp = options.ldp || new LDP(config) - const host = options.host || SolidHost.from({ port: config.port, serverUri: config.serverUri }) - return AccountManager.from({ - host, - store: ldp, - multiuser: config.multiuser - }) -} - -export function loadConfig (program, options) { - let argv = { - ...options, - version: program.version() - } - const configFile = argv.configFile || './config.json' - try { - const file = fs.readFileSync(configFile) - const config = JSON.parse(file) - argv = { ...config, ...argv } - } catch (err) { - if (typeof argv.configFile !== 'undefined') { - if (!fs.existsSync(configFile)) { - console.log(red(bold('ERR')), 'Config file ' + configFile + " doesn't exist.") - process.exit(1) - } - } - if (fs.existsSync(configFile)) { - console.log(red(bold('ERR')), 'config file ' + configFile + " couldn't be parsed: " + err) - process.exit(1) - } - console.log(cyan(bold('TIP')), 'create a config.json: `$ solid init`') - } - return argv -} - -export function loadAccounts ({ root, serverUri, hostname }) { - const files = fs.readdirSync(root) - hostname = hostname || new URL(serverUri).hostname - const isUserDirectory = new RegExp(`.${hostname}$`) - return files.filter(file => isUserDirectory.test(file)) -} - -export function loadUsernames ({ root, serverUri }) { - const hostname = new URL(serverUri).hostname - return loadAccounts({ root, hostname }).map(userDirectory => userDirectory.substr(0, userDirectory.length - hostname.length - 1)) -} +import fs from 'fs-extra' +import { red, cyan, bold } from 'colorette' +import { URL } from 'url' +import LDP from '../../lib/ldp.js' +import AccountManager from '../../lib/models/account-manager.js' +import SolidHost from '../../lib/models/solid-host.js' + +export function getAccountManager (config, options = {}) { + const ldp = options.ldp || new LDP(config) + const host = options.host || SolidHost.from({ port: config.port, serverUri: config.serverUri }) + return AccountManager.from({ + host, + store: ldp, + multiuser: config.multiuser + }) +} + +export function loadConfig (program, options) { + let argv = { + ...options, + version: program.version() + } + const configFile = argv.configFile || './config.json' + try { + const file = fs.readFileSync(configFile) + const config = JSON.parse(file) + argv = { ...config, ...argv } + } catch (err) { + if (typeof argv.configFile !== 'undefined') { + if (!fs.existsSync(configFile)) { + console.log(red(bold('ERR')), 'Config file ' + configFile + " doesn't exist.") + process.exit(1) + } + } + if (fs.existsSync(configFile)) { + console.log(red(bold('ERR')), 'config file ' + configFile + " couldn't be parsed: " + err) + process.exit(1) + } + console.log(cyan(bold('TIP')), 'create a config.json: `$ solid init`') + } + return argv +} + +export function loadAccounts ({ root, serverUri, hostname }) { + const files = fs.readdirSync(root) + hostname = hostname || new URL(serverUri).hostname + const isUserDirectory = new RegExp(`.${hostname}$`) + return files.filter(file => isUserDirectory.test(file)) +} + +export function loadUsernames ({ root, serverUri }) { + const hostname = new URL(serverUri).hostname + return loadAccounts({ root, hostname }).map(userDirectory => userDirectory.substr(0, userDirectory.length - hostname.length - 1)) +} diff --git a/bin/lib/cli.mjs b/bin/lib/cli.js similarity index 84% rename from bin/lib/cli.mjs rename to bin/lib/cli.js index 24cb62e4e..e53f22b4c 100644 --- a/bin/lib/cli.mjs +++ b/bin/lib/cli.js @@ -1,44 +1,44 @@ -import { Command } from 'commander' -import loadInit from './init.mjs' -import loadStart from './start.mjs' -import loadInvalidUsernames from './invalidUsernames.mjs' -import loadMigrateLegacyResources from './migrateLegacyResources.mjs' -import loadUpdateIndex from './updateIndex.mjs' -import { spawnSync } from 'child_process' -import path from 'path' -import fs from 'fs' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -export default function startCli (server) { - const program = new Command() - program.version(getVersion()) - - loadInit(program) - loadStart(program, server) - loadInvalidUsernames(program) - loadMigrateLegacyResources(program) - loadUpdateIndex(program) - - program.parse(process.argv) - if (program.args.length === 0) program.help() -} - -function getVersion () { - try { - const options = { cwd: __dirname, encoding: 'utf8' } - const { stdout } = spawnSync('git', ['describe', '--tags'], options) - const { stdout: gitStatusStdout } = spawnSync('git', ['status'], options) - const version = stdout.trim() - if (version === '' || gitStatusStdout.match('Not currently on any branch')) { - throw new Error('No git version here') - } - return version - } catch (e) { - const pkgPath = path.join(__dirname, '../../package.json') - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) - return pkg.version - } -} +import { Command } from 'commander' +import loadInit from './init.js' +import loadStart from './start.js' +import loadInvalidUsernames from './invalidUsernames.js' +import loadMigrateLegacyResources from './migrateLegacyResources.js' +import loadUpdateIndex from './updateIndex.js' +import { spawnSync } from 'child_process' +import path from 'path' +import fs from 'fs' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export default function startCli (server) { + const program = new Command() + program.version(getVersion()) + + loadInit(program) + loadStart(program, server) + loadInvalidUsernames(program) + loadMigrateLegacyResources(program) + loadUpdateIndex(program) + + program.parse(process.argv) + if (program.args.length === 0) program.help() +} + +function getVersion () { + try { + const options = { cwd: __dirname, encoding: 'utf8' } + const { stdout } = spawnSync('git', ['describe', '--tags'], options) + const { stdout: gitStatusStdout } = spawnSync('git', ['status'], options) + const version = stdout.trim() + if (version === '' || gitStatusStdout.match('Not currently on any branch')) { + throw new Error('No git version here') + } + return version + } catch (e) { + const pkgPath = path.join(__dirname, '../../package.json') + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) + return pkg.version + } +} diff --git a/bin/lib/init.mjs b/bin/lib/init.js similarity index 94% rename from bin/lib/init.mjs rename to bin/lib/init.js index 06074699e..432de35e2 100644 --- a/bin/lib/init.mjs +++ b/bin/lib/init.js @@ -1,93 +1,93 @@ -import inquirer from 'inquirer' -import fs from 'fs' -import options from './options.mjs' -import camelize from 'camelize' - -const questions = options - .map((option) => { - if (!option.type) { - if (option.flag) { - option.type = 'confirm' - } else { - option.type = 'input' - } - } - - option.message = option.question || option.help - return option - }) - -export default function (program) { - program - .command('init') - .option('--advanced', 'Ask for all the settings') - .description('create solid server configurations') - .action((opts) => { - // Filter out advanced commands - let filtered = questions - if (!opts.advanced) { - filtered = filtered.filter((option) => option.prompt) - } - - // Prompt to the user - inquirer.prompt(filtered) - .then((answers) => { - manipulateEmailSection(answers) - manipulateServerSection(answers) - cleanupAnswers(answers) - - // write config file - const config = JSON.stringify(camelize(answers), null, ' ') - const configPath = process.cwd() + '/config.json' - - fs.writeFile(configPath, config, (err) => { - if (err) { - return console.log('failed to write config.json') - } - console.log('config created on', configPath) - }) - }) - .catch((err) => { - console.log('Error:', err) - }) - }) -} - -function cleanupAnswers (answers) { - Object.keys(answers).forEach((answer) => { - if (answer.startsWith('use')) { - delete answers[answer] - } - }) -} - -function manipulateEmailSection (answers) { - if (answers.useEmail) { - answers.email = { - host: answers['email-host'], - port: answers['email-port'], - secure: true, - auth: { - user: answers['email-auth-user'], - pass: answers['email-auth-pass'] - } - } - delete answers['email-host'] - delete answers['email-port'] - delete answers['email-auth-user'] - delete answers['email-auth-pass'] - } -} - -function manipulateServerSection (answers) { - answers.server = { - name: answers['server-info-name'], - description: answers['server-info-description'], - logo: answers['server-info-logo'] - } - Object.keys(answers).forEach((answer) => { - if (answer.startsWith('server-info-')) { - delete answers[answer] - } - }) -} +import inquirer from 'inquirer' +import fs from 'fs' +import options from './options.js' +import camelize from 'camelize' + +const questions = options + .map((option) => { + if (!option.type) { + if (option.flag) { + option.type = 'confirm' + } else { + option.type = 'input' + } + } + + option.message = option.question || option.help + return option + }) + +export default function (program) { + program + .command('init') + .option('--advanced', 'Ask for all the settings') + .description('create solid server configurations') + .action((opts) => { + // Filter out advanced commands + let filtered = questions + if (!opts.advanced) { + filtered = filtered.filter((option) => option.prompt) + } + + // Prompt to the user + inquirer.prompt(filtered) + .then((answers) => { + manipulateEmailSection(answers) + manipulateServerSection(answers) + cleanupAnswers(answers) + + // write config file + const config = JSON.stringify(camelize(answers), null, ' ') + const configPath = process.cwd() + '/config.json' + + fs.writeFile(configPath, config, (err) => { + if (err) { + return console.log('failed to write config.json') + } + console.log('config created on', configPath) + }) + }) + .catch((err) => { + console.log('Error:', err) + }) + }) +} + +function cleanupAnswers (answers) { + Object.keys(answers).forEach((answer) => { + if (answer.startsWith('use')) { + delete answers[answer] + } + }) +} + +function manipulateEmailSection (answers) { + if (answers.useEmail) { + answers.email = { + host: answers['email-host'], + port: answers['email-port'], + secure: true, + auth: { + user: answers['email-auth-user'], + pass: answers['email-auth-pass'] + } + } + delete answers['email-host'] + delete answers['email-port'] + delete answers['email-auth-user'] + delete answers['email-auth-pass'] + } +} + +function manipulateServerSection (answers) { + answers.server = { + name: answers['server-info-name'], + description: answers['server-info-description'], + logo: answers['server-info-logo'] + } + Object.keys(answers).forEach((answer) => { + if (answer.startsWith('server-info-')) { + delete answers[answer] + } + }) +} diff --git a/bin/lib/invalidUsernames.mjs b/bin/lib/invalidUsernames.js similarity index 93% rename from bin/lib/invalidUsernames.mjs rename to bin/lib/invalidUsernames.js index 6ad4f4bdd..b94ea428d 100644 --- a/bin/lib/invalidUsernames.mjs +++ b/bin/lib/invalidUsernames.js @@ -1,136 +1,136 @@ -import fs from 'fs-extra' -import Handlebars from 'handlebars' -import path from 'path' -import { getAccountManager, loadConfig, loadUsernames } from './cli-utils.mjs' -import { isValidUsername } from '../../lib/common/user-utils.mjs' -import blacklistService from '../../lib/services/blacklist-service.mjs' -import { initConfigDir, initTemplateDirs } from '../../lib/server-config.mjs' -import { fromServerConfig } from '../../lib/models/oidc-manager.mjs' -import EmailService from '../../lib/services/email-service.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' - -export default function (program) { - program - .command('invalidusernames') - .option('--notify', 'Will notify users with usernames that are invalid') - .option('--delete', 'Will delete users with usernames that are invalid') - .description('Manage usernames that are invalid') - .action(async (options) => { - const config = loadConfig(program, options) - if (!config.multiuser) { - return console.error('You are running a single user server, no need to check for invalid usernames') - } - const invalidUsernames = getInvalidUsernames(config) - const host = SolidHost.from({ port: config.port, serverUri: config.serverUri }) - const accountManager = getAccountManager(config, { host }) - if (options.notify) { - return notifyUsers(invalidUsernames, accountManager, config) - } - if (options.delete) { - return deleteUsers(invalidUsernames, accountManager, config, host) - } - listUsernames(invalidUsernames) - }) -} - -function backupIndexFile (username, accountManager, invalidUsernameTemplate, dateOfRemoval, supportEmail) { - const userDirectory = accountManager.accountDirFor(username) - const currentIndex = path.join(userDirectory, 'index.html') - const currentIndexExists = fs.existsSync(currentIndex) - const backupIndex = path.join(userDirectory, 'index.backup.html') - const backupIndexExists = fs.existsSync(backupIndex) - if (currentIndexExists && !backupIndexExists) { - fs.renameSync(currentIndex, backupIndex) - createNewIndexAcl(userDirectory) - createNewIndex(username, invalidUsernameTemplate, dateOfRemoval, supportEmail, currentIndex) - console.info(`index.html updated for user ${username}`) - } -} - -function createNewIndex (username, invalidUsernameTemplate, dateOfRemoval, supportEmail, currentIndex) { - const newIndexSource = invalidUsernameTemplate({ - username, - dateOfRemoval, - supportEmail - }) - fs.writeFileSync(currentIndex, newIndexSource, 'utf-8') -} - -function createNewIndexAcl (userDirectory) { - const currentIndexAcl = path.join(userDirectory, 'index.html.acl') - const backupIndexAcl = path.join(userDirectory, 'index.backup.html.acl') - const currentIndexSource = fs.readFileSync(currentIndexAcl, 'utf-8') - const backupIndexSource = currentIndexSource.replace(/index.html/g, 'index.backup.html') - fs.writeFileSync(backupIndexAcl, backupIndexSource, 'utf-8') -} - -async function deleteUsers (usernames, accountManager, config, host) { - const oidcManager = fromServerConfig({ ...config, host }) - const deletingUsers = usernames.map(async username => { - try { - const user = accountManager.userAccountFrom({ username }) - await oidcManager.users.deleteUser(user) - } catch (error) { - if (error.message !== 'No email given') { - throw error - } - } - const userDirectory = accountManager.accountDirFor(username) - await fs.remove(userDirectory) - }) - await Promise.all(deletingUsers) - console.info(`Deleted ${deletingUsers.length} users succeeded`) -} - -function getInvalidUsernames (config) { - const usernames = loadUsernames(config) - return usernames.filter(username => !isValidUsername(username) || !blacklistService.validate(username)) -} - -function listUsernames (usernames) { - if (usernames.length === 0) { - return console.info('No invalid usernames was found') - } - console.info(`${usernames.length} invalid usernames were found:${usernames.map(username => `\n- ${username}`)}`) -} - -async function notifyUsers (usernames, accountManager, config) { - const twoWeeksFromNow = Date.now() + 14 * 24 * 60 * 60 * 1000 - const dateOfRemoval = (new Date(twoWeeksFromNow)).toLocaleDateString() - const { supportEmail } = config - updateIndexFiles(usernames, accountManager, dateOfRemoval, supportEmail) - await sendEmails(config, usernames, accountManager, dateOfRemoval, supportEmail) -} - -async function sendEmails (config, usernames, accountManager, dateOfRemoval, supportEmail) { - if (config.email && config.email.host) { - const configPath = initConfigDir(config) - const templates = initTemplateDirs(configPath) - const users = await Promise.all(await usernames.map(async username => { - const emailAddress = await accountManager.loadAccountRecoveryEmail({ username }) - const accountUri = accountManager.accountUriFor(username) - return { username, emailAddress, accountUri } - })) - const emailService = new EmailService(templates.email, config.email) - const sendingEmails = users - .filter(user => !!user.emailAddress) - .map(user => emailService.sendWithTemplate('invalid-username.mjs', { - to: user.emailAddress, - accountUri: user.accountUri, - dateOfRemoval, - supportEmail - })) - const emailsSent = await Promise.all(sendingEmails) - console.info(`${emailsSent.length} emails sent to users with invalid usernames`) - return - } - console.info('You have not configured an email service.') - console.info('Please set it up to send users email about their accounts') -} - -function updateIndexFiles (usernames, accountManager, dateOfRemoval, supportEmail) { - const invalidUsernameFilePath = path.join(process.cwd(), 'default-views', 'account', 'invalid-username.hbs') - const source = fs.readFileSync(invalidUsernameFilePath, 'utf-8') - const invalidUsernameTemplate = Handlebars.compile(source) - usernames.forEach(username => backupIndexFile(username, accountManager, invalidUsernameTemplate, dateOfRemoval, supportEmail)) -} +import fs from 'fs-extra' +import Handlebars from 'handlebars' +import path from 'path' +import { getAccountManager, loadConfig, loadUsernames } from './cli-utils.js' +import { isValidUsername } from '../../lib/common/user-utils.js' +import blacklistService from '../../lib/services/blacklist-service.js' +import { initConfigDir, initTemplateDirs } from '../../lib/server-config.js' +import { fromServerConfig } from '../../lib/models/oidc-manager.js' +import EmailService from '../../lib/services/email-service.js' +import SolidHost from '../../lib/models/solid-host.js' + +export default function (program) { + program + .command('invalidusernames') + .option('--notify', 'Will notify users with usernames that are invalid') + .option('--delete', 'Will delete users with usernames that are invalid') + .description('Manage usernames that are invalid') + .action(async (options) => { + const config = loadConfig(program, options) + if (!config.multiuser) { + return console.error('You are running a single user server, no need to check for invalid usernames') + } + const invalidUsernames = getInvalidUsernames(config) + const host = SolidHost.from({ port: config.port, serverUri: config.serverUri }) + const accountManager = getAccountManager(config, { host }) + if (options.notify) { + return notifyUsers(invalidUsernames, accountManager, config) + } + if (options.delete) { + return deleteUsers(invalidUsernames, accountManager, config, host) + } + listUsernames(invalidUsernames) + }) +} + +function backupIndexFile (username, accountManager, invalidUsernameTemplate, dateOfRemoval, supportEmail) { + const userDirectory = accountManager.accountDirFor(username) + const currentIndex = path.join(userDirectory, 'index.html') + const currentIndexExists = fs.existsSync(currentIndex) + const backupIndex = path.join(userDirectory, 'index.backup.html') + const backupIndexExists = fs.existsSync(backupIndex) + if (currentIndexExists && !backupIndexExists) { + fs.renameSync(currentIndex, backupIndex) + createNewIndexAcl(userDirectory) + createNewIndex(username, invalidUsernameTemplate, dateOfRemoval, supportEmail, currentIndex) + console.info(`index.html updated for user ${username}`) + } +} + +function createNewIndex (username, invalidUsernameTemplate, dateOfRemoval, supportEmail, currentIndex) { + const newIndexSource = invalidUsernameTemplate({ + username, + dateOfRemoval, + supportEmail + }) + fs.writeFileSync(currentIndex, newIndexSource, 'utf-8') +} + +function createNewIndexAcl (userDirectory) { + const currentIndexAcl = path.join(userDirectory, 'index.html.acl') + const backupIndexAcl = path.join(userDirectory, 'index.backup.html.acl') + const currentIndexSource = fs.readFileSync(currentIndexAcl, 'utf-8') + const backupIndexSource = currentIndexSource.replace(/index.html/g, 'index.backup.html') + fs.writeFileSync(backupIndexAcl, backupIndexSource, 'utf-8') +} + +async function deleteUsers (usernames, accountManager, config, host) { + const oidcManager = fromServerConfig({ ...config, host }) + const deletingUsers = usernames.map(async username => { + try { + const user = accountManager.userAccountFrom({ username }) + await oidcManager.users.deleteUser(user) + } catch (error) { + if (error.message !== 'No email given') { + throw error + } + } + const userDirectory = accountManager.accountDirFor(username) + await fs.remove(userDirectory) + }) + await Promise.all(deletingUsers) + console.info(`Deleted ${deletingUsers.length} users succeeded`) +} + +function getInvalidUsernames (config) { + const usernames = loadUsernames(config) + return usernames.filter(username => !isValidUsername(username) || !blacklistService.validate(username)) +} + +function listUsernames (usernames) { + if (usernames.length === 0) { + return console.info('No invalid usernames was found') + } + console.info(`${usernames.length} invalid usernames were found:${usernames.map(username => `\n- ${username}`)}`) +} + +async function notifyUsers (usernames, accountManager, config) { + const twoWeeksFromNow = Date.now() + 14 * 24 * 60 * 60 * 1000 + const dateOfRemoval = (new Date(twoWeeksFromNow)).toLocaleDateString() + const { supportEmail } = config + updateIndexFiles(usernames, accountManager, dateOfRemoval, supportEmail) + await sendEmails(config, usernames, accountManager, dateOfRemoval, supportEmail) +} + +async function sendEmails (config, usernames, accountManager, dateOfRemoval, supportEmail) { + if (config.email && config.email.host) { + const configPath = initConfigDir(config) + const templates = initTemplateDirs(configPath) + const users = await Promise.all(await usernames.map(async username => { + const emailAddress = await accountManager.loadAccountRecoveryEmail({ username }) + const accountUri = accountManager.accountUriFor(username) + return { username, emailAddress, accountUri } + })) + const emailService = new EmailService(templates.email, config.email) + const sendingEmails = users + .filter(user => !!user.emailAddress) + .map(user => emailService.sendWithTemplate('invalid-username.js', { + to: user.emailAddress, + accountUri: user.accountUri, + dateOfRemoval, + supportEmail + })) + const emailsSent = await Promise.all(sendingEmails) + console.info(`${emailsSent.length} emails sent to users with invalid usernames`) + return + } + console.info('You have not configured an email service.') + console.info('Please set it up to send users email about their accounts') +} + +function updateIndexFiles (usernames, accountManager, dateOfRemoval, supportEmail) { + const invalidUsernameFilePath = path.join(process.cwd(), 'default-views', 'account', 'invalid-username.hbs') + const source = fs.readFileSync(invalidUsernameFilePath, 'utf-8') + const invalidUsernameTemplate = Handlebars.compile(source) + usernames.forEach(username => backupIndexFile(username, accountManager, invalidUsernameTemplate, dateOfRemoval, supportEmail)) +} diff --git a/bin/lib/migrateLegacyResources.mjs b/bin/lib/migrateLegacyResources.js similarity index 96% rename from bin/lib/migrateLegacyResources.mjs rename to bin/lib/migrateLegacyResources.js index d015b2080..b5f2707c0 100644 --- a/bin/lib/migrateLegacyResources.mjs +++ b/bin/lib/migrateLegacyResources.js @@ -1,64 +1,64 @@ -import fs from 'fs' -import Path from 'path' -import { promisify } from 'util' -const readdir = promisify(fs.readdir) -const lstat = promisify(fs.lstat) -const rename = promisify(fs.rename) - -export default function (program) { - program - .command('migrate-legacy-resources') - .option('-p, --path ', 'Path to the data folder, defaults to \'data/\'') - .option('-s, --suffix ', 'The suffix to add to extensionless files, defaults to \'$.ttl\'') - .option('-v, --verbose', 'Path to the data folder') - .description('Migrate the data folder from node-solid-server 4 to node-solid-server 5') - .action(async (opts) => { - const verbose = opts.verbose - const suffix = opts.suffix || '$.ttl' - let paths = opts.path ? [opts.path] : ['data', 'config/templates'] - paths = paths.map(path => path.startsWith(Path.sep) ? path : Path.join(process.cwd(), path)) - try { - for (const path of paths) { - if (verbose) { - console.log(`Migrating files in ${path}`) - } - await migrate(path, suffix, verbose) - } - } catch (err) { - console.error(err) - } - }) -} - -async function migrate (path, suffix, verbose) { - const files = await readdir(path) - for (const file of files) { - const fullFilePath = Path.join(path, file) - const stat = await lstat(fullFilePath) - if (stat.isFile()) { - if (shouldMigrateFile(file)) { - const newFullFilePath = getNewFileName(fullFilePath, suffix) - if (verbose) { - console.log(`${fullFilePath}\n => ${newFullFilePath}`) - } - await rename(fullFilePath, newFullFilePath) - } - } else { - if (shouldMigrateFolder(file)) { - await migrate(fullFilePath, suffix, verbose) - } - } - } -} - -function getNewFileName (fullFilePath, suffix) { - return fullFilePath + suffix -} - -function shouldMigrateFile (filename) { - return filename.indexOf('.') < 0 -} - -function shouldMigrateFolder (foldername) { - return foldername[0] !== '.' -} +import fs from 'fs' +import Path from 'path' +import { promisify } from 'util' +const readdir = promisify(fs.readdir) +const lstat = promisify(fs.lstat) +const rename = promisify(fs.rename) + +export default function (program) { + program + .command('migrate-legacy-resources') + .option('-p, --path ', 'Path to the data folder, defaults to \'data/\'') + .option('-s, --suffix ', 'The suffix to add to extensionless files, defaults to \'$.ttl\'') + .option('-v, --verbose', 'Path to the data folder') + .description('Migrate the data folder from node-solid-server 4 to node-solid-server 5') + .action(async (opts) => { + const verbose = opts.verbose + const suffix = opts.suffix || '$.ttl' + let paths = opts.path ? [opts.path] : ['data', 'config/templates'] + paths = paths.map(path => path.startsWith(Path.sep) ? path : Path.join(process.cwd(), path)) + try { + for (const path of paths) { + if (verbose) { + console.log(`Migrating files in ${path}`) + } + await migrate(path, suffix, verbose) + } + } catch (err) { + console.error(err) + } + }) +} + +async function migrate (path, suffix, verbose) { + const files = await readdir(path) + for (const file of files) { + const fullFilePath = Path.join(path, file) + const stat = await lstat(fullFilePath) + if (stat.isFile()) { + if (shouldMigrateFile(file)) { + const newFullFilePath = getNewFileName(fullFilePath, suffix) + if (verbose) { + console.log(`${fullFilePath}\n => ${newFullFilePath}`) + } + await rename(fullFilePath, newFullFilePath) + } + } else { + if (shouldMigrateFolder(file)) { + await migrate(fullFilePath, suffix, verbose) + } + } + } +} + +function getNewFileName (fullFilePath, suffix) { + return fullFilePath + suffix +} + +function shouldMigrateFile (filename) { + return filename.indexOf('.') < 0 +} + +function shouldMigrateFolder (foldername) { + return foldername[0] !== '.' +} diff --git a/bin/lib/options.mjs b/bin/lib/options.js similarity index 96% rename from bin/lib/options.mjs rename to bin/lib/options.js index 6c335b442..75c2a921f 100644 --- a/bin/lib/options.mjs +++ b/bin/lib/options.js @@ -1,379 +1,379 @@ -import fs from 'fs' -import path from 'path' -import validUrl from 'valid-url' -import { URL } from 'url' -import validator from 'validator' -const { isEmail } = validator - -const options = [ - { - name: 'root', - help: "Root folder to serve (default: './data')", - question: 'Path to the folder you want to serve. Default is', - default: './data', - prompt: true, - filter: (value) => path.resolve(value) - }, - { - name: 'port', - help: 'SSL port to use', - question: 'SSL port to run on. Default is', - default: '8443', - prompt: true - }, - { - name: 'server-uri', - question: 'Solid server uri (with protocol, hostname and port)', - help: "Solid server uri (default: 'https://localhost:8443')", - default: 'https://localhost:8443', - validate: validUri, - prompt: true - }, - { - name: 'webid', - help: 'Enable WebID authentication and access control (uses HTTPS)', - flag: true, - default: true, - question: 'Enable WebID authentication', - prompt: true - }, - { - name: 'mount', - help: "Serve on a specific URL path (default: '/')", - question: 'Serve Solid on URL path', - default: '/', - prompt: true - }, - { - name: 'config-path', - question: 'Path to the config directory (for example: ./config)', - default: './config', - prompt: true - }, - { - name: 'config-file', - question: 'Path to the config file (for example: ./config.json)', - default: './config.json', - prompt: true - }, - { - name: 'db-path', - question: 'Path to the server metadata db directory (for users/apps etc)', - default: './.db', - prompt: true - }, - { - name: 'auth', - help: 'Pick an authentication strategy for WebID: `tls` or `oidc`', - question: 'Select authentication strategy', - type: 'list', - choices: [ - 'WebID-OpenID Connect' - ], - prompt: false, - default: 'WebID-OpenID Connect', - filter: (value) => { - if (value === 'WebID-OpenID Connect') return 'oidc' - }, - when: (answers) => answers.webid - }, - { - name: 'use-owner', - question: 'Do you already have a WebID?', - type: 'confirm', - default: false, - hide: true - }, - { - name: 'owner', - help: 'Set the owner of the storage (overwrites the root ACL file)', - question: 'Your webid (to overwrite the root ACL with)', - prompt: false, - validate: function (value) { - if (value === '' || !value.startsWith('http')) { - return 'Enter a valid Webid' - } - return true - }, - when: function (answers) { - return answers['use-owner'] - } - }, - { - name: 'ssl-key', - help: 'Path to the SSL private key in PEM format', - validate: validPath, - prompt: true - }, - { - name: 'ssl-cert', - help: 'Path to the SSL certificate key in PEM format', - validate: validPath, - prompt: true - }, - { - name: 'no-reject-unauthorized', - help: 'Accept self-signed certificates', - flag: true, - default: false, - prompt: false - }, - { - name: 'multiuser', - help: 'Enable multi-user mode', - question: 'Enable multi-user mode', - flag: true, - default: false, - prompt: true - }, - { - name: 'idp', - help: 'Obsolete; use --multiuser', - prompt: false - }, - { - name: 'no-live', - help: 'Disable live support through WebSockets', - flag: true, - default: false - }, - { - name: 'no-prep', - help: 'Disable Per Resource Events', - flag: true, - default: false - }, - { - name: 'use-cors-proxy', - help: 'Do you want to have a CORS proxy endpoint?', - flag: true, - default: false, - hide: true - }, - { - name: 'proxy', - help: 'Obsolete; use --corsProxy', - prompt: false - }, - { - name: 'cors-proxy', - help: 'Serve the CORS proxy on this path', - when: function (answers) { - return answers['use-cors-proxy'] - }, - default: '/proxy', - prompt: true - }, - { - name: 'auth-proxy', - help: 'Object with path/server pairs to reverse proxy', - default: {}, - prompt: false, - hide: true - }, - { - name: 'suppress-data-browser', - help: 'Suppress provision of a data browser', - flag: true, - prompt: false, - default: false, - hide: false - }, - { - name: 'data-browser-path', - help: 'An HTML file which is sent to allow users to browse the data (eg using mashlib.js)', - question: 'Path of data viewer page (defaults to using mashlib)', - validate: validPath, - default: 'default', - prompt: false - }, - { - name: 'suffix-acl', - full: 'suffix-acl', - help: "Suffix for acl files (default: '.acl')", - default: '.acl', - prompt: false - }, - { - name: 'suffix-meta', - full: 'suffix-meta', - help: "Suffix for metadata files (default: '.meta')", - default: '.meta', - prompt: false - }, - { - name: 'secret', - help: 'Secret used to sign the session ID cookie (e.g. "your secret phrase")', - question: 'Session secret for cookie', - default: 'random', - prompt: false, - filter: function (value) { - if (value === '' || value === 'random') { - return - } - return value - } - }, - { - name: 'error-pages', - help: 'Folder from which to look for custom error pages files (files must be named .html -- eg. 500.html)', - validate: validPath, - prompt: false - }, - { - name: 'force-user', - help: 'Force a WebID to always be logged in (useful when offline)' - }, - { - name: 'strict-origin', - help: 'Enforce same origin policy in the ACL', - flag: true, - default: false, - prompt: false - }, - { - name: 'use-email', - help: 'Do you want to set up an email service?', - flag: true, - prompt: true, - default: false - }, - { - name: 'email-host', - help: 'Host of your email service', - prompt: true, - default: 'smtp.gmail.com', - when: (answers) => answers['use-email'] - }, - { - name: 'email-port', - help: 'Port of your email service', - prompt: true, - default: '465', - when: (answers) => answers['use-email'] - }, - { - name: 'email-auth-user', - help: 'User of your email service', - prompt: true, - when: (answers) => answers['use-email'], - validate: (value) => { - if (!value) { - return 'You must enter this information' - } - return true - } - }, - { - name: 'email-auth-pass', - help: 'Password of your email service', - type: 'password', - prompt: true, - when: (answers) => answers['use-email'] - }, - { - name: 'use-api-apps', - help: 'Do you want to load your default apps on /api/apps?', - flag: true, - prompt: false, - default: true - }, - { - name: 'api-apps', - help: 'Path to the folder to mount on /api/apps', - prompt: true, - validate: validPath, - when: (answers) => answers['use-api-apps'] - }, - { - name: 'redirect-http-from', - help: 'HTTP port or comma-separated ports to redirect to the solid server port (e.g. "80,8080").', - prompt: false, - validate: function (value) { - if (!value.match(/^[0-9]+(,[0-9]+)*$/)) { - return 'direct-port(s) must be a comma-separated list of integers.' - } - const list = value.split(/,/).map(v => parseInt(v)) - const bad = list.find(v => { return v < 1 || v > 65535 }) - if (bad && bad.length) { - return 'redirect-http-from port(s) ' + bad + ' out of range' - } - return true - } - }, - { - name: 'server-info-name', - help: 'A name for your server (not required, but will be presented on your server\'s frontpage)', - prompt: true, - default: answers => new URL(answers['server-uri']).hostname - }, - { - name: 'server-info-description', - help: 'A description of your server (not required)', - prompt: true - }, - { - name: 'server-info-logo', - help: 'A logo that represents you, your brand, or your server (not required)', - prompt: true - }, - { - name: 'enforce-toc', - help: 'Do you want to enforce Terms & Conditions for your service?', - flag: true, - prompt: true, - default: false, - when: answers => answers.multiuser - }, - { - name: 'toc-uri', - help: 'URI to your Terms & Conditions', - prompt: true, - validate: validUri, - when: answers => answers['enforce-toc'] - }, - { - name: 'disable-password-checks', - help: 'Do you want to disable password strength checking?', - flag: true, - prompt: true, - default: false, - when: answers => answers.multiuser - }, - { - name: 'support-email', - help: 'The support email you provide for your users (not required)', - prompt: true, - validate: (value) => { - if (value && !isEmail(value)) { - return 'Must be a valid email' - } - return true - }, - when: answers => answers.multiuser - } -] - -function validPath (value) { - if (value === 'default') { - return Promise.resolve(true) - } - if (!value) { - return Promise.resolve('You must enter a valid path') - } - return new Promise((resolve) => { - fs.stat(value, function (err) { - if (err) return resolve('Nothing found at this path') - return resolve(true) - }) - }) -} - -function validUri (value) { - if (!validUrl.isUri(value)) { - return 'Enter a valid uri (with protocol)' - } - return true -} - -export default options +import fs from 'fs' +import path from 'path' +import validUrl from 'valid-url' +import { URL } from 'url' +import validator from 'validator' +const { isEmail } = validator + +const options = [ + { + name: 'root', + help: "Root folder to serve (default: './data')", + question: 'Path to the folder you want to serve. Default is', + default: './data', + prompt: true, + filter: (value) => path.resolve(value) + }, + { + name: 'port', + help: 'SSL port to use', + question: 'SSL port to run on. Default is', + default: '8443', + prompt: true + }, + { + name: 'server-uri', + question: 'Solid server uri (with protocol, hostname and port)', + help: "Solid server uri (default: 'https://localhost:8443')", + default: 'https://localhost:8443', + validate: validUri, + prompt: true + }, + { + name: 'webid', + help: 'Enable WebID authentication and access control (uses HTTPS)', + flag: true, + default: true, + question: 'Enable WebID authentication', + prompt: true + }, + { + name: 'mount', + help: "Serve on a specific URL path (default: '/')", + question: 'Serve Solid on URL path', + default: '/', + prompt: true + }, + { + name: 'config-path', + question: 'Path to the config directory (for example: ./config)', + default: './config', + prompt: true + }, + { + name: 'config-file', + question: 'Path to the config file (for example: ./config.json)', + default: './config.json', + prompt: true + }, + { + name: 'db-path', + question: 'Path to the server metadata db directory (for users/apps etc)', + default: './.db', + prompt: true + }, + { + name: 'auth', + help: 'Pick an authentication strategy for WebID: `tls` or `oidc`', + question: 'Select authentication strategy', + type: 'list', + choices: [ + 'WebID-OpenID Connect' + ], + prompt: false, + default: 'WebID-OpenID Connect', + filter: (value) => { + if (value === 'WebID-OpenID Connect') return 'oidc' + }, + when: (answers) => answers.webid + }, + { + name: 'use-owner', + question: 'Do you already have a WebID?', + type: 'confirm', + default: false, + hide: true + }, + { + name: 'owner', + help: 'Set the owner of the storage (overwrites the root ACL file)', + question: 'Your webid (to overwrite the root ACL with)', + prompt: false, + validate: function (value) { + if (value === '' || !value.startsWith('http')) { + return 'Enter a valid Webid' + } + return true + }, + when: function (answers) { + return answers['use-owner'] + } + }, + { + name: 'ssl-key', + help: 'Path to the SSL private key in PEM format', + validate: validPath, + prompt: true + }, + { + name: 'ssl-cert', + help: 'Path to the SSL certificate key in PEM format', + validate: validPath, + prompt: true + }, + { + name: 'no-reject-unauthorized', + help: 'Accept self-signed certificates', + flag: true, + default: false, + prompt: false + }, + { + name: 'multiuser', + help: 'Enable multi-user mode', + question: 'Enable multi-user mode', + flag: true, + default: false, + prompt: true + }, + { + name: 'idp', + help: 'Obsolete; use --multiuser', + prompt: false + }, + { + name: 'no-live', + help: 'Disable live support through WebSockets', + flag: true, + default: false + }, + { + name: 'no-prep', + help: 'Disable Per Resource Events', + flag: true, + default: false + }, + { + name: 'use-cors-proxy', + help: 'Do you want to have a CORS proxy endpoint?', + flag: true, + default: false, + hide: true + }, + { + name: 'proxy', + help: 'Obsolete; use --corsProxy', + prompt: false + }, + { + name: 'cors-proxy', + help: 'Serve the CORS proxy on this path', + when: function (answers) { + return answers['use-cors-proxy'] + }, + default: '/proxy', + prompt: true + }, + { + name: 'auth-proxy', + help: 'Object with path/server pairs to reverse proxy', + default: {}, + prompt: false, + hide: true + }, + { + name: 'suppress-data-browser', + help: 'Suppress provision of a data browser', + flag: true, + prompt: false, + default: false, + hide: false + }, + { + name: 'data-browser-path', + help: 'An HTML file which is sent to allow users to browse the data (eg using mashlib.js)', + question: 'Path of data viewer page (defaults to using mashlib)', + validate: validPath, + default: 'default', + prompt: false + }, + { + name: 'suffix-acl', + full: 'suffix-acl', + help: "Suffix for acl files (default: '.acl')", + default: '.acl', + prompt: false + }, + { + name: 'suffix-meta', + full: 'suffix-meta', + help: "Suffix for metadata files (default: '.meta')", + default: '.meta', + prompt: false + }, + { + name: 'secret', + help: 'Secret used to sign the session ID cookie (e.g. "your secret phrase")', + question: 'Session secret for cookie', + default: 'random', + prompt: false, + filter: function (value) { + if (value === '' || value === 'random') { + return + } + return value + } + }, + { + name: 'error-pages', + help: 'Folder from which to look for custom error pages files (files must be named .html -- eg. 500.html)', + validate: validPath, + prompt: false + }, + { + name: 'force-user', + help: 'Force a WebID to always be logged in (useful when offline)' + }, + { + name: 'strict-origin', + help: 'Enforce same origin policy in the ACL', + flag: true, + default: false, + prompt: false + }, + { + name: 'use-email', + help: 'Do you want to set up an email service?', + flag: true, + prompt: true, + default: false + }, + { + name: 'email-host', + help: 'Host of your email service', + prompt: true, + default: 'smtp.gmail.com', + when: (answers) => answers['use-email'] + }, + { + name: 'email-port', + help: 'Port of your email service', + prompt: true, + default: '465', + when: (answers) => answers['use-email'] + }, + { + name: 'email-auth-user', + help: 'User of your email service', + prompt: true, + when: (answers) => answers['use-email'], + validate: (value) => { + if (!value) { + return 'You must enter this information' + } + return true + } + }, + { + name: 'email-auth-pass', + help: 'Password of your email service', + type: 'password', + prompt: true, + when: (answers) => answers['use-email'] + }, + { + name: 'use-api-apps', + help: 'Do you want to load your default apps on /api/apps?', + flag: true, + prompt: false, + default: true + }, + { + name: 'api-apps', + help: 'Path to the folder to mount on /api/apps', + prompt: true, + validate: validPath, + when: (answers) => answers['use-api-apps'] + }, + { + name: 'redirect-http-from', + help: 'HTTP port or comma-separated ports to redirect to the solid server port (e.g. "80,8080").', + prompt: false, + validate: function (value) { + if (!value.match(/^[0-9]+(,[0-9]+)*$/)) { + return 'direct-port(s) must be a comma-separated list of integers.' + } + const list = value.split(/,/).map(v => parseInt(v)) + const bad = list.find(v => { return v < 1 || v > 65535 }) + if (bad && bad.length) { + return 'redirect-http-from port(s) ' + bad + ' out of range' + } + return true + } + }, + { + name: 'server-info-name', + help: 'A name for your server (not required, but will be presented on your server\'s frontpage)', + prompt: true, + default: answers => new URL(answers['server-uri']).hostname + }, + { + name: 'server-info-description', + help: 'A description of your server (not required)', + prompt: true + }, + { + name: 'server-info-logo', + help: 'A logo that represents you, your brand, or your server (not required)', + prompt: true + }, + { + name: 'enforce-toc', + help: 'Do you want to enforce Terms & Conditions for your service?', + flag: true, + prompt: true, + default: false, + when: answers => answers.multiuser + }, + { + name: 'toc-uri', + help: 'URI to your Terms & Conditions', + prompt: true, + validate: validUri, + when: answers => answers['enforce-toc'] + }, + { + name: 'disable-password-checks', + help: 'Do you want to disable password strength checking?', + flag: true, + prompt: true, + default: false, + when: answers => answers.multiuser + }, + { + name: 'support-email', + help: 'The support email you provide for your users (not required)', + prompt: true, + validate: (value) => { + if (value && !isEmail(value)) { + return 'Must be a valid email' + } + return true + }, + when: answers => answers.multiuser + } +] + +function validPath (value) { + if (value === 'default') { + return Promise.resolve(true) + } + if (!value) { + return Promise.resolve('You must enter a valid path') + } + return new Promise((resolve) => { + fs.stat(value, function (err) { + if (err) return resolve('Nothing found at this path') + return resolve(true) + }) + }) +} + +function validUri (value) { + if (!validUrl.isUri(value)) { + return 'Enter a valid uri (with protocol)' + } + return true +} + +export default options diff --git a/bin/lib/start.mjs b/bin/lib/start.js similarity index 93% rename from bin/lib/start.mjs rename to bin/lib/start.js index 9ef770c9a..65d02f989 100644 --- a/bin/lib/start.mjs +++ b/bin/lib/start.js @@ -1,124 +1,124 @@ -import options from './options.mjs' -import fs from 'fs' -import path from 'path' -import { loadConfig } from './cli-utils.mjs' -import { red, bold } from 'colorette' - -export default function (program, server) { - const start = program - .command('start') - .description('run the Solid server') - - options - .filter((option) => !option.hide) - .forEach((option) => { - const configName = option.name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) - const snakeCaseName = configName.replace(/([A-Z])/g, '_$1') - const envName = `SOLID_${snakeCaseName.toUpperCase()}` - let name = '--' + option.name - if (!option.flag) { - name += ' [value]' - } - if (process.env[envName]) { - const raw = process.env[envName] - const envValue = /^(true|false)$/.test(raw) ? raw === 'true' : raw - start.option(name, option.help, envValue) - } else { - start.option(name, option.help) - } - }) - - start.option('-q, --quiet', 'Do not print the logs to console') - - start.action(async (options) => { - const config = loadConfig(program, options) - await bin(config, server) - }) -} - -async function bin (argv, server) { - if (!argv.email) { - argv.email = { - host: argv.emailHost, - port: argv.emailPort, - secure: true, - auth: { - user: argv.emailAuthUser, - pass: argv.emailAuthPass - } - } - delete argv.emailHost - delete argv.emailPort - delete argv.emailAuthUser - delete argv.emailAuthPass - } - if (!argv.tokenTypesSupported) { - argv.tokenTypesSupported = ['legacyPop', 'dpop'] - } - argv.live = !argv.noLive - if (!argv.quiet) { - const debug = await import('debug') - debug.default.enable('solid:*') - } - argv.port = argv.port || 3456 - if (argv.webid !== false) { - argv.webid = true - } - if (!argv.webid && argv.multiuser) { - throw new Error('Server cannot operate as multiuser without webids') - } - if (process.platform !== 'win32') { - process.on('SIGINT', function () { - console.log('\nSolid stopped.') - process.exit() - }) - } - if (argv.owner) { - let rootPath = path.resolve(argv.root || process.cwd()) - if (!(rootPath.endsWith('/'))) { - rootPath += '/' - } - rootPath += (argv.suffixAcl || '.acl') - const defaultAcl = `@prefix n0: . - @prefix n2: . - - <#owner> - a n0:Authorization; - n0:accessTo <./>; - n0:agent <${argv.owner}>; - n0:default <./>; - n0:mode n0:Control, n0:Read, n0:Write. - <#everyone> - a n0:Authorization; - n0: n2:Agent; - n0:accessTo <./>; - n0:default <./>; - n0:mode n0:Read.` - fs.writeFileSync(rootPath, defaultAcl) - } - const solid = (await import('../../index.mjs')).default - let app - try { - app = solid.createServer(argv, server) - } catch (e) { - if (e.code === 'EACCES') { - if (e.syscall === 'mkdir') { - console.log(red(bold('ERROR')), `You need permissions to create '${e.path}' folder`) - } else { - console.log(red(bold('ERROR')), 'You need root privileges to start on this port') - } - return 1 - } - if (e.code === 'EADDRINUSE') { - console.log(red(bold('ERROR')), 'The port ' + argv.port + ' is already in use') - return 1 - } - console.log(red(bold('ERROR')), e.message) - return 1 - } - app.listen(argv.port, function () { - console.log('ESM Solid server') - console.log(`Solid server (${argv.version}) running on \u001b[4mhttps://localhost:${argv.port}/\u001b[0m`) - console.log('Press +c to stop') - }) -} +import options from './options.js' +import fs from 'fs' +import path from 'path' +import { loadConfig } from './cli-utils.js' +import { red, bold } from 'colorette' + +export default function (program, server) { + const start = program + .command('start') + .description('run the Solid server') + + options + .filter((option) => !option.hide) + .forEach((option) => { + const configName = option.name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) + const snakeCaseName = configName.replace(/([A-Z])/g, '_$1') + const envName = `SOLID_${snakeCaseName.toUpperCase()}` + let name = '--' + option.name + if (!option.flag) { + name += ' [value]' + } + if (process.env[envName]) { + const raw = process.env[envName] + const envValue = /^(true|false)$/.test(raw) ? raw === 'true' : raw + start.option(name, option.help, envValue) + } else { + start.option(name, option.help) + } + }) + + start.option('-q, --quiet', 'Do not print the logs to console') + + start.action(async (options) => { + const config = loadConfig(program, options) + await bin(config, server) + }) +} + +async function bin (argv, server) { + if (!argv.email) { + argv.email = { + host: argv.emailHost, + port: argv.emailPort, + secure: true, + auth: { + user: argv.emailAuthUser, + pass: argv.emailAuthPass + } + } + delete argv.emailHost + delete argv.emailPort + delete argv.emailAuthUser + delete argv.emailAuthPass + } + if (!argv.tokenTypesSupported) { + argv.tokenTypesSupported = ['legacyPop', 'dpop'] + } + argv.live = !argv.noLive + if (!argv.quiet) { + const debug = await import('debug') + debug.default.enable('solid:*') + } + argv.port = argv.port || 3456 + if (argv.webid !== false) { + argv.webid = true + } + if (!argv.webid && argv.multiuser) { + throw new Error('Server cannot operate as multiuser without webids') + } + if (process.platform !== 'win32') { + process.on('SIGINT', function () { + console.log('\nSolid stopped.') + process.exit() + }) + } + if (argv.owner) { + let rootPath = path.resolve(argv.root || process.cwd()) + if (!(rootPath.endsWith('/'))) { + rootPath += '/' + } + rootPath += (argv.suffixAcl || '.acl') + const defaultAcl = `@prefix n0: . + @prefix n2: . + + <#owner> + a n0:Authorization; + n0:accessTo <./>; + n0:agent <${argv.owner}>; + n0:default <./>; + n0:mode n0:Control, n0:Read, n0:Write. + <#everyone> + a n0:Authorization; + n0: n2:Agent; + n0:accessTo <./>; + n0:default <./>; + n0:mode n0:Read.` + fs.writeFileSync(rootPath, defaultAcl) + } + const solid = (await import('../../index.js')).default + let app + try { + app = solid.createServer(argv, server) + } catch (e) { + if (e.code === 'EACCES') { + if (e.syscall === 'mkdir') { + console.log(red(bold('ERROR')), `You need permissions to create '${e.path}' folder`) + } else { + console.log(red(bold('ERROR')), 'You need root privileges to start on this port') + } + return 1 + } + if (e.code === 'EADDRINUSE') { + console.log(red(bold('ERROR')), 'The port ' + argv.port + ' is already in use') + return 1 + } + console.log(red(bold('ERROR')), e.message) + return 1 + } + app.listen(argv.port, function () { + console.log('ESM Solid server') + console.log(`Solid server (${argv.version}) running on \u001b[4mhttps://localhost:${argv.port}/\u001b[0m`) + console.log('Press +c to stop') + }) +} diff --git a/bin/lib/updateIndex.mjs b/bin/lib/updateIndex.js similarity index 84% rename from bin/lib/updateIndex.mjs rename to bin/lib/updateIndex.js index 30413242f..d23757ab4 100644 --- a/bin/lib/updateIndex.mjs +++ b/bin/lib/updateIndex.js @@ -1,55 +1,55 @@ -import fs from 'fs' -import path from 'path' -import * as cheerio from 'cheerio' -import LDP from '../../lib/ldp.mjs' -import { URL } from 'url' -import debug from '../../lib/debug.mjs' -import { readFile } from '../../lib/common/fs-utils.mjs' -import { compileTemplate, writeTemplate } from '../../lib/common/template-utils.mjs' -import { loadConfig, loadAccounts } from './cli-utils.mjs' -import { getName, getWebId } from '../../lib/common/user-utils.mjs' -import { initConfigDir, initTemplateDirs } from '../../lib/server-config.mjs' - -export default function (program) { - program - .command('updateindex.mjs') - .description('Update index.html in root of all PODs that haven\'t been marked otherwise') - .action(async (options) => { - const config = loadConfig(program, options) - const configPath = initConfigDir(config) - const templates = initTemplateDirs(configPath) - const indexTemplatePath = path.join(templates.account, 'index.html') - const indexTemplate = await compileTemplate(indexTemplatePath) - const ldp = new LDP(config) - const accounts = loadAccounts(config) - const usersProcessed = accounts.map(async account => { - const accountDirectory = path.join(config.root, account) - const indexFilePath = path.join(accountDirectory, '/index.html') - if (!isUpdateAllowed(indexFilePath)) { - return - } - const accountUrl = getAccountUrl(account, config) - try { - const webId = await getWebId(accountDirectory, accountUrl, ldp.suffixMeta, (filePath) => readFile(filePath)) - const name = await getName(webId, ldp.fetchGraph) - writeTemplate(indexFilePath, indexTemplate, { name, webId }) - } catch (err) { - debug.errors(`Failed to create new index for ${account}: ${JSON.stringify(err, null, 2)}`) - } - }) - await Promise.all(usersProcessed) - debug.accounts(`Processed ${usersProcessed.length} users`) - }) -} - -function getAccountUrl (name, config) { - const serverUrl = new URL(config.serverUri) - return `${serverUrl.protocol}//${name}.${serverUrl.host}/` -} - -function isUpdateAllowed (indexFilePath) { - const indexSource = fs.readFileSync(indexFilePath, 'utf-8') - const $ = cheerio.load(indexSource) - const allowAutomaticUpdateValue = $('meta[name="solid-allow-automatic-updates"]').prop('content') - return !allowAutomaticUpdateValue || allowAutomaticUpdateValue === 'true' -} +import fs from 'fs' +import path from 'path' +import * as cheerio from 'cheerio' +import LDP from '../../lib/ldp.js' +import { URL } from 'url' +import debug from '../../lib/debug.js' +import { readFile } from '../../lib/common/fs-utils.js' +import { compileTemplate, writeTemplate } from '../../lib/common/template-utils.js' +import { loadConfig, loadAccounts } from './cli-utils.js' +import { getName, getWebId } from '../../lib/common/user-utils.js' +import { initConfigDir, initTemplateDirs } from '../../lib/server-config.js' + +export default function (program) { + program + .command('updateindex.js') + .description('Update index.html in root of all PODs that haven\'t been marked otherwise') + .action(async (options) => { + const config = loadConfig(program, options) + const configPath = initConfigDir(config) + const templates = initTemplateDirs(configPath) + const indexTemplatePath = path.join(templates.account, 'index.html') + const indexTemplate = await compileTemplate(indexTemplatePath) + const ldp = new LDP(config) + const accounts = loadAccounts(config) + const usersProcessed = accounts.map(async account => { + const accountDirectory = path.join(config.root, account) + const indexFilePath = path.join(accountDirectory, '/index.html') + if (!isUpdateAllowed(indexFilePath)) { + return + } + const accountUrl = getAccountUrl(account, config) + try { + const webId = await getWebId(accountDirectory, accountUrl, ldp.suffixMeta, (filePath) => readFile(filePath)) + const name = await getName(webId, ldp.fetchGraph) + writeTemplate(indexFilePath, indexTemplate, { name, webId }) + } catch (err) { + debug.errors(`Failed to create new index for ${account}: ${JSON.stringify(err, null, 2)}`) + } + }) + await Promise.all(usersProcessed) + debug.accounts(`Processed ${usersProcessed.length} users`) + }) +} + +function getAccountUrl (name, config) { + const serverUrl = new URL(config.serverUri) + return `${serverUrl.protocol}//${name}.${serverUrl.host}/` +} + +function isUpdateAllowed (indexFilePath) { + const indexSource = fs.readFileSync(indexFilePath, 'utf-8') + const $ = cheerio.load(indexSource) + const allowAutomaticUpdateValue = $('meta[name="solid-allow-automatic-updates"]').prop('content') + return !allowAutomaticUpdateValue || allowAutomaticUpdateValue === 'true' +} diff --git a/bin/solid b/bin/solid index 5c705088a..45b6a6526 100755 --- a/bin/solid +++ b/bin/solid @@ -1,3 +1,3 @@ #!/usr/bin/env node -import startCli from './lib/cli.mjs' +import startCli from './lib/cli.js' startCli() diff --git a/common/js/auth-buttons.mjs b/common/js/auth-buttons.js similarity index 96% rename from common/js/auth-buttons.mjs rename to common/js/auth-buttons.js index 8823aa21d..1f8830158 100644 --- a/common/js/auth-buttons.mjs +++ b/common/js/auth-buttons.js @@ -1,57 +1,57 @@ -// ESM version of auth-buttons.js -// global: location, alert, solid - -((auth) => { - // Wire up DOM elements - const [ - loginButton, - logoutButton, - registerButton, - accountSettings, - loggedInContainer, - profileLink - ] = [ - 'login', - 'logout', - 'register', - 'account-settings', - 'loggedIn', - 'profileLink' - ].map(id => document.getElementById(id) || document.createElement('a')) - loginButton.addEventListener('click', login) - logoutButton.addEventListener('click', logout) - registerButton.addEventListener('click', register) - - // Track authentication status and update UI - auth.trackSession(session => { - const loggedIn = !!session - const isOwner = loggedIn && new URL(session.webId).origin === location.origin - loginButton.classList.toggle('hidden', loggedIn) - logoutButton.classList.toggle('hidden', !loggedIn) - registerButton.classList.toggle('hidden', loggedIn) - accountSettings.classList.toggle('hidden', !isOwner) - loggedInContainer.classList.toggle('hidden', !loggedIn) - if (session) { - profileLink.href = session.webId - profileLink.innerText = session.webId - } - }) - - // Log the user in on the client and the server - async function login () { - alert(`login from this page is no more possible.\n\nYou must ask the pod owner to modify this page or remove it.`) - // Deprecated code omitted - } - - // Log the user out from the client and the server - async function logout () { - await auth.logout() - location.reload() - } - - // Redirect to the registration page - function register () { - const registration = new URL('/register', location) - location.href = registration - } -})(solid) +// ESM version of auth-buttons.js +// global: location, alert, solid + +((auth) => { + // Wire up DOM elements + const [ + loginButton, + logoutButton, + registerButton, + accountSettings, + loggedInContainer, + profileLink + ] = [ + 'login', + 'logout', + 'register', + 'account-settings', + 'loggedIn', + 'profileLink' + ].map(id => document.getElementById(id) || document.createElement('a')) + loginButton.addEventListener('click', login) + logoutButton.addEventListener('click', logout) + registerButton.addEventListener('click', register) + + // Track authentication status and update UI + auth.trackSession(session => { + const loggedIn = !!session + const isOwner = loggedIn && new URL(session.webId).origin === location.origin + loginButton.classList.toggle('hidden', loggedIn) + logoutButton.classList.toggle('hidden', !loggedIn) + registerButton.classList.toggle('hidden', loggedIn) + accountSettings.classList.toggle('hidden', !isOwner) + loggedInContainer.classList.toggle('hidden', !loggedIn) + if (session) { + profileLink.href = session.webId + profileLink.innerText = session.webId + } + }) + + // Log the user in on the client and the server + async function login () { + alert(`login from this page is no more possible.\n\nYou must ask the pod owner to modify this page or remove it.`) + // Deprecated code omitted + } + + // Log the user out from the client and the server + async function logout () { + await auth.logout() + location.reload() + } + + // Redirect to the registration page + function register () { + const registration = new URL('/register', location) + location.href = registration + } +})(solid) diff --git a/common/js/index-buttons.mjs b/common/js/index-buttons.js similarity index 97% rename from common/js/index-buttons.mjs rename to common/js/index-buttons.js index 8b75dd99b..6d2561682 100644 --- a/common/js/index-buttons.mjs +++ b/common/js/index-buttons.js @@ -1,44 +1,44 @@ -// ESM version of index-buttons.js -/* global SolidLogic */ -'use strict' -const keyname = 'SolidServerRootRedirectLink' -/* function register () { - alert(2) - window.location.href = '/register' -} */ -document.addEventListener('DOMContentLoaded', async function () { - const authn = SolidLogic.authn - const authSession = SolidLogic.authSession - - if (!authn.currentUser()) await authn.checkUser() - const user = authn.currentUser() - - // IF LOGGED IN: SET SolidServerRootRedirectLink. LOGOUT - if (user) { - window.localStorage.setItem(keyname, user.uri) - await authSession.logout() - } else { - const webId = window.localStorage.getItem(keyname) - // IF NOT LOGGED IN AND COOKIE EXISTS: REMOVE COOKIE, HIDE WELCOME, SHOW LINK TO PROFILE - if (webId) { - window.localStorage.removeItem(keyname) - document.getElementById('loggedIn').style.display = 'block' - document.getElementById('loggedIn').innerHTML = `

Your WebID is : ${webId}.

Visit your profile to log into your Pod.

` - - // IF NOT LOGGED IN AND COOKIE DOES NOT EXIST - // SHOW WELCOME, SHOW LOGIN BUTTON - // HIDE LOGIN BUTTON, ADD REGISTER BUTTON - } else { - const loginArea = document.getElementById('loginStatusArea') - const html = `` - const span = document.createElement('span') - span.innerHTML = html - loginArea.appendChild(span) - loginArea.appendChild(UI.login.loginStatusBox(document, null, {})) - const logInButton = loginArea.querySelectorAll('input')[1] - logInButton.value = 'Log in to see your WebID' - const signUpButton = loginArea.querySelectorAll('input')[2] - signUpButton.style.display = 'none' - } - } -}) +// ESM version of index-buttons.js +/* global SolidLogic */ +'use strict' +const keyname = 'SolidServerRootRedirectLink' +/* function register () { + alert(2) + window.location.href = '/register' +} */ +document.addEventListener('DOMContentLoaded', async function () { + const authn = SolidLogic.authn + const authSession = SolidLogic.authSession + + if (!authn.currentUser()) await authn.checkUser() + const user = authn.currentUser() + + // IF LOGGED IN: SET SolidServerRootRedirectLink. LOGOUT + if (user) { + window.localStorage.setItem(keyname, user.uri) + await authSession.logout() + } else { + const webId = window.localStorage.getItem(keyname) + // IF NOT LOGGED IN AND COOKIE EXISTS: REMOVE COOKIE, HIDE WELCOME, SHOW LINK TO PROFILE + if (webId) { + window.localStorage.removeItem(keyname) + document.getElementById('loggedIn').style.display = 'block' + document.getElementById('loggedIn').innerHTML = `

Your WebID is : ${webId}.

Visit your profile to log into your Pod.

` + + // IF NOT LOGGED IN AND COOKIE DOES NOT EXIST + // SHOW WELCOME, SHOW LOGIN BUTTON + // HIDE LOGIN BUTTON, ADD REGISTER BUTTON + } else { + const loginArea = document.getElementById('loginStatusArea') + const html = `` + const span = document.createElement('span') + span.innerHTML = html + loginArea.appendChild(span) + loginArea.appendChild(UI.login.loginStatusBox(document, null, {})) + const logInButton = loginArea.querySelectorAll('input')[1] + logInButton.value = 'Log in to see your WebID' + const signUpButton = loginArea.querySelectorAll('input')[2] + signUpButton.style.display = 'none' + } + } +}) diff --git a/common/js/solid.js b/common/js/solid.js index b186273db..c026428f6 100644 --- a/common/js/solid.js +++ b/common/js/solid.js @@ -1,4 +1,3 @@ -/* global owaspPasswordStrengthTest, TextEncoder, crypto, fetch */ (function () { 'use strict' diff --git a/common/js/solid.mjs b/common/js/solid.mjs deleted file mode 100644 index e2660bf43..000000000 --- a/common/js/solid.mjs +++ /dev/null @@ -1,456 +0,0 @@ -// ESM version of solid.js -// global: owaspPasswordStrengthTest, TextEncoder, crypto, fetch - -(function () { - 'use strict' - - const PasswordValidator = function (passwordField, repeatedPasswordField) { - if ( - passwordField === null || passwordField === undefined || - repeatedPasswordField === null || repeatedPasswordField === undefined - ) { - return - } - - this.passwordField = passwordField - this.repeatedPasswordField = repeatedPasswordField - - this.fetchDomNodes() - this.bindEvents() - - this.currentStrengthLevel = 0 - this.errors = [] - } - - const FEEDBACK_SUCCESS = 'success' - const FEEDBACK_WARNING = 'warning' - const FEEDBACK_ERROR = 'error' - - const ICON_SUCCESS = 'glyphicon-ok' - const ICON_WARNING = 'glyphicon-warning-sign' - const ICON_ERROR = 'glyphicon-remove' - - const VALIDATION_SUCCESS = 'has-success' - const VALIDATION_WARNING = 'has-warning' - const VALIDATION_ERROR = 'has-error' - - const STRENGTH_PROGRESS_0 = 'progress-bar-danger level-0' - const STRENGTH_PROGRESS_1 = 'progress-bar-danger level-1' - const STRENGTH_PROGRESS_2 = 'progress-bar-warning level-2' - const STRENGTH_PROGRESS_3 = 'progress-bar-success level-3' - const STRENGTH_PROGRESS_4 = 'progress-bar-success level-4' - - /** - * Prefetch all dom nodes at initialisation in order to gain time at execution since DOM manipulations - * are really time consuming - */ - PasswordValidator.prototype.fetchDomNodes = function () { - this.form = this.passwordField.closest('form') - - this.disablePasswordChecks = this.passwordField.classList.contains('disable-password-checks') - - this.passwordGroup = this.passwordField.closest('.form-group') - this.passwordFeedback = this.passwordGroup.querySelector('.form-control-feedback') - this.passwordStrengthMeter = this.passwordGroup.querySelector('.progress-bar') - this.passwordHelpText = this.passwordGroup.querySelector('.help-block') - - this.repeatedPasswordGroup = this.repeatedPasswordField.closest('.form-group') - this.repeatedPasswordFeedback = this.repeatedPasswordGroup.querySelector('.form-control-feedback') - } - - PasswordValidator.prototype.bindEvents = function () { - this.passwordField.addEventListener('focus', this.resetPasswordFeedback.bind(this)) - this.passwordField.addEventListener('keyup', this.instantFeedbackForPassword.bind(this)) - this.repeatedPasswordField.addEventListener('keyup', this.validateRepeatedPassword.bind(this)) - this.passwordField.addEventListener('blur', this.validatePassword.bind(this)) - } - - /** - * Events Listeners - */ - - PasswordValidator.prototype.resetPasswordFeedback = function () { - this.errors = [] - this.resetValidation(this.passwordGroup) - this.resetFeedbackIcon(this.passwordFeedback) - if (!this.disablePasswordChecks) { - this.displayPasswordErrors() - this.instantFeedbackForPassword() - } - } - - /** - * Validate password on the fly to provide the user a visual strength meter - */ - PasswordValidator.prototype.instantFeedbackForPassword = function () { - const passwordStrength = this.getPasswordStrength(this.passwordField.value) - const strengthLevel = this.getStrengthLevel(passwordStrength) - - if (this.currentStrengthLevel === strengthLevel) { - return - } - - this.currentStrengthLevel = strengthLevel - - this.updateStrengthMeter() - - if (this.repeatedPasswordField.value !== '') { - this.updateRepeatedPasswordFeedback() - } - } - - /** - * Validate password and display the error(s) message(s) - */ - PasswordValidator.prototype.validatePassword = function () { - this.errors = [] - const password = this.passwordField.value - - if (!this.disablePasswordChecks) { - const passwordStrength = this.getPasswordStrength(password) - this.currentStrengthLevel = this.getStrengthLevel(passwordStrength) - - if (passwordStrength.errors) { - this.addPasswordError(passwordStrength.errors) - } - - this.checkLeakedPassword(password).then(this.handleLeakedPasswordResponse.bind(this)) - } - - this.setPasswordFeedback() - } - - /** - * Validate the repeated password upon typing - */ - PasswordValidator.prototype.validateRepeatedPassword = function () { - this.updateRepeatedPasswordFeedback() - } - - /** - * User Feedback manipulators - */ - - /** - * Update the strength meter based on OWASP feedback - */ - PasswordValidator.prototype.updateStrengthMeter = function () { - this.resetStrengthMeter() - - this.passwordStrengthMeter.classList.add.apply( - this.passwordStrengthMeter.classList, - this.tokenize(this.getStrengthLevelProgressClass()) - ) - } - - PasswordValidator.prototype.setPasswordFeedback = function () { - const feedback = this.getFeedbackFromLevel() - this.updateStrengthMeter() - this.displayPasswordErrors() - this.setFeedbackForField(feedback, this.passwordField) - } - - /** - * Update the repeated password feedback icon and color - */ - PasswordValidator.prototype.updateRepeatedPasswordFeedback = function () { - const feedback = this.checkPasswordFieldsEquality() ? FEEDBACK_SUCCESS : FEEDBACK_ERROR - this.setFeedbackForField(feedback, this.repeatedPasswordField) - } - - /** - * Display the given feedback on the field - * @param {string} feedback success|error|warning - * @param {HTMLElement} field - */ - PasswordValidator.prototype.setFeedbackForField = function (feedback, field) { - const formGroup = this.getFormGroupElementForField(field) - const visualFeedback = this.getFeedbackElementForField(field) - - this.resetValidation(formGroup) - this.resetFeedbackIcon(visualFeedback) - - visualFeedback.classList.remove('hidden') - - visualFeedback.classList - .add - .apply( - visualFeedback.classList, - this.tokenize(this.getFeedbackIconClass(feedback)) - ) - - formGroup.classList - .add - .apply( - formGroup.classList, - this.tokenize(this.getValidationClass(feedback)) - ) - } - - /** - * Password Strength Helpers - */ - - /** - * Get OWASP feedback on the given password. Returns false if the password is empty - * @param password - * @returns {object|false} - */ - PasswordValidator.prototype.getPasswordStrength = function (password) { - if (password === '') { - return false - } - return owaspPasswordStrengthTest.test(password) - } - - /** - * Get the password strength level based on password strength feedback object given by OWASP - * @param passwordStrength - * @returns {number} - */ - PasswordValidator.prototype.getStrengthLevel = function (passwordStrength) { - if (passwordStrength === false) { - return 0 - } - if (passwordStrength.requiredTestErrors.length !== 0) { - return 1 - } - - if (passwordStrength.strong === false) { - return 2 - } - - if (passwordStrength.isPassphrase === false || passwordStrength.optionalTestErrors.length !== 0) { - return 3 - } - - return 4 - } - - PasswordValidator.prototype.LEVEL_TO_FEEDBACK_MAP = [ - FEEDBACK_ERROR, - FEEDBACK_ERROR, - FEEDBACK_WARNING, - FEEDBACK_SUCCESS, - FEEDBACK_SUCCESS - ] - - /** - * @returns {string} - */ - PasswordValidator.prototype.getFeedbackFromLevel = function () { - return this.LEVEL_TO_FEEDBACK_MAP[this.currentStrengthLevel] - } - - PasswordValidator.prototype.LEVEL_TO_PROGRESS_MAP = [ - STRENGTH_PROGRESS_0, - STRENGTH_PROGRESS_1, - STRENGTH_PROGRESS_2, - STRENGTH_PROGRESS_3, - STRENGTH_PROGRESS_4 - ] - - /** - * Get the CSS class for the meter based on the current level - */ - PasswordValidator.prototype.getStrengthLevelProgressClass = function () { - return this.LEVEL_TO_PROGRESS_MAP[this.currentStrengthLevel] - } - - PasswordValidator.prototype.addPasswordError = function (error) { - this.errors.push(...(Array.isArray(error) ? error : [error])) - } - - PasswordValidator.prototype.displayPasswordErrors = function () { - // Erase the error list content - while (this.passwordHelpText.firstChild) { - this.passwordHelpText.removeChild(this.passwordHelpText.firstChild) - } - - // Add the errors in the stack to the DOM - this.errors.map((error) => { - const text = document.createTextNode(error) - const paragraph = document.createElement('p') - paragraph.appendChild(text) - this.passwordHelpText.appendChild(paragraph) - }) - } - - PasswordValidator.prototype.FEEDBACK_TO_ICON_MAP = [] - PasswordValidator.prototype.FEEDBACK_TO_ICON_MAP[FEEDBACK_SUCCESS] = ICON_SUCCESS - PasswordValidator.prototype.FEEDBACK_TO_ICON_MAP[FEEDBACK_WARNING] = ICON_WARNING - PasswordValidator.prototype.FEEDBACK_TO_ICON_MAP[FEEDBACK_ERROR] = ICON_ERROR - - /** - * @param success|error|warning feedback - */ - PasswordValidator.prototype.getFeedbackIconClass = function (feedback) { - return this.FEEDBACK_TO_ICON_MAP[feedback] - } - - PasswordValidator.prototype.FEEDBACK_TO_VALIDATION_MAP = [] - PasswordValidator.prototype.FEEDBACK_TO_VALIDATION_MAP[FEEDBACK_SUCCESS] = VALIDATION_SUCCESS - PasswordValidator.prototype.FEEDBACK_TO_VALIDATION_MAP[FEEDBACK_WARNING] = VALIDATION_WARNING - PasswordValidator.prototype.FEEDBACK_TO_VALIDATION_MAP[FEEDBACK_ERROR] = VALIDATION_ERROR - - /** - * @param success|error|warning feedback - */ - PasswordValidator.prototype.getValidationClass = function (feedback) { - return this.FEEDBACK_TO_VALIDATION_MAP[feedback] - } - - /** - * Validators - */ - - /** - * Check if both password fields are equal - * @returns {boolean} - */ - PasswordValidator.prototype.checkPasswordFieldsEquality = function () { - return this.passwordField.value === this.repeatedPasswordField.value - } - - /** - * Check if the password is leaked - * @param password - */ - PasswordValidator.prototype.checkLeakedPassword = function (password) { - const url = 'https://api.pwnedpasswords.com/range/' - - return new Promise(function (resolve, reject) { - this.sha1(password).then((digest) => { - const preFix = digest.slice(0, 5) - let suffix = digest.slice(5, digest.length) - suffix = suffix.toUpperCase() - - return fetch(url + preFix) - .then(function (response) { - return response.text() - }) - .then(function (data) { - resolve(data.indexOf(suffix) > -1) - }) - .catch(function (err) { - reject(err) - }) - }) - }.bind(this)) - } - - PasswordValidator.prototype.handleLeakedPasswordResponse = function (hasPasswordLeaked) { - if (hasPasswordLeaked === true) { - this.currentStrengthLevel-- - this.addPasswordError('This password was exposed in a data breach. Please use a more secure alternative one!') - } - - this.setPasswordFeedback() - } - - /** - * CSS Classes reseters - */ - - PasswordValidator.prototype.resetValidation = function (el) { - const tokenizedClasses = this.tokenize( - VALIDATION_ERROR, - VALIDATION_WARNING, - VALIDATION_SUCCESS - ) - - el.classList.remove.apply( - el.classList, - tokenizedClasses - ) - } - - PasswordValidator.prototype.resetFeedbackIcon = function (el) { - const tokenizedClasses = this.tokenize( - ICON_ERROR, - ICON_WARNING, - ICON_SUCCESS - ) - - el.classList.remove.apply( - el.classList, - tokenizedClasses - ) - } - - PasswordValidator.prototype.resetStrengthMeter = function () { - const tokenizedClasses = this.tokenize( - STRENGTH_PROGRESS_1, - STRENGTH_PROGRESS_2, - STRENGTH_PROGRESS_3, - STRENGTH_PROGRESS_4 - ) - - this.passwordStrengthMeter.classList.remove.apply( - this.passwordStrengthMeter.classList, - tokenizedClasses - ) - } - - /** - * Helpers - */ - - PasswordValidator.prototype.getFormGroupElementForField = function (field) { - if (field === this.passwordField) { - return this.passwordGroup - } - - if (field === this.repeatedPasswordField) { - return this.repeatedPasswordGroup - } - } - - PasswordValidator.prototype.getFeedbackElementForField = function (field) { - if (field === this.passwordField) { - return this.passwordFeedback - } - - if (field === this.repeatedPasswordField) { - return this.repeatedPasswordFeedback - } - } - - /** - * Returns an array of strings ready to be applied on classList.add or classList.remove - * @returns {string[]} - */ - PasswordValidator.prototype.tokenize = function () { - const tokenArray = [] - for (const i in arguments) { - tokenArray.push(arguments[i]) - } - return tokenArray.join(' ').split(' ') - } - - PasswordValidator.prototype.sha1 = function (str) { - const buffer = new TextEncoder('utf-8').encode(str) - - return crypto.subtle.digest('SHA-1', buffer).then((hash) => { - return this.hex(hash) - }) - } - - PasswordValidator.prototype.hex = function (buffer) { - const hexCodes = [] - const view = new DataView(buffer) - for (let i = 0; i < view.byteLength; i += 4) { - const value = view.getUint32(i) - const stringValue = value.toString(16) - const padding = '00000000' - const paddedValue = (padding + stringValue).slice(-padding.length) - hexCodes.push(paddedValue) - } - return hexCodes.join('') - } - - new PasswordValidator( - document.getElementById('password'), - document.getElementById('repeat_password') - ) -})() diff --git a/config/defaults.mjs b/config/defaults.js similarity index 96% rename from config/defaults.mjs rename to config/defaults.js index 62c9b448e..d3c75a54e 100644 --- a/config/defaults.mjs +++ b/config/defaults.js @@ -1,22 +1,22 @@ -export default { - auth: 'oidc', - localAuth: { - tls: true, - password: true - }, - configPath: './config', - dbPath: './.db', - port: 8443, - serverUri: 'https://localhost:8443', - webid: true, - strictOrigin: true, - trustedOrigins: [], - dataBrowserPath: 'default' - // For use in Enterprises to configure a HTTP proxy for all outbound HTTP requests from the SOLID server (we use - // https://www.npmjs.com/package/global-tunnel-ng). - // "httpProxy": { - // "tunnel": "neither", - // "host": "proxy.example.com", - // "port": 12345 - // } -} +export default { + auth: 'oidc', + localAuth: { + tls: true, + password: true + }, + configPath: './config', + dbPath: './.db', + port: 8443, + serverUri: 'https://localhost:8443', + webid: true, + strictOrigin: true, + trustedOrigins: [], + dataBrowserPath: 'default' + // For use in Enterprises to configure a HTTP proxy for all outbound HTTP requests from the SOLID server (we use + // https://www.npmjs.com/package/global-tunnel-ng). + // "httpProxy": { + // "tunnel": "neither", + // "host": "proxy.example.com", + // "port": 12345 + // } +} diff --git a/test/resources/config/templates/emails/delete-account.mjs b/default-templates/emails/delete-account.js similarity index 95% rename from test/resources/config/templates/emails/delete-account.mjs rename to default-templates/emails/delete-account.js index c8c98d915..95feba7f5 100644 --- a/test/resources/config/templates/emails/delete-account.mjs +++ b/default-templates/emails/delete-account.js @@ -1,31 +1,31 @@ -export function render (data) { - return { - subject: 'Delete Solid-account request', - - /** - * Text version - */ - text: `Hi, - -We received a request to delete your Solid account, ${data.webId} - -To delete your account, click on the following link: - -${data.deleteUrl} - -If you did not mean to delete your account, ignore this email.`, - - /** - * HTML version - */ - html: `

Hi,

- -

We received a request to delete your Solid account, ${data.webId}

- -

To delete your account, click on the following link:

- -

${data.deleteUrl}

- -

If you did not mean to delete your account, ignore this email.

` - } -} +export function render (data) { + return { + subject: 'Delete Solid-account request', + + /** + * Text version + */ + text: `Hi, + +We received a request to delete your Solid account, ${data.webId} + +To delete your account, click on the following link: + +${data.deleteUrl} + +If you did not mean to delete your account, ignore this email.`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We received a request to delete your Solid account, ${data.webId}

+ +

To delete your account, click on the following link:

+ +

${data.deleteUrl}

+ +

If you did not mean to delete your account, ignore this email.

` + } +} diff --git a/test/resources/config/templates/emails/invalid-username.mjs b/default-templates/emails/invalid-username.js similarity index 96% rename from test/resources/config/templates/emails/invalid-username.mjs rename to default-templates/emails/invalid-username.js index 7f0351d77..7303c31bb 100644 --- a/test/resources/config/templates/emails/invalid-username.mjs +++ b/default-templates/emails/invalid-username.js @@ -1,27 +1,27 @@ -export function render (data) { - return { - subject: `Invalid username for account ${data.accountUri}`, - - /** - * Text version - */ - text: `Hi, - -We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy. - -This account has been set to be deleted at ${data.dateOfRemoval}. - -${data.supportEmail ? `Please contact ${data.supportEmail} if you want to move your account.` : ''}`, - - /** - * HTML version - */ - html: `

Hi,

- -

We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy.

- -

This account has been set to be deleted at ${data.dateOfRemoval}.

- -${data.supportEmail ? `

Please contact ${data.supportEmail} if you want to move your account.

` : ''}` - } -} +export function render (data) { + return { + subject: `Invalid username for account ${data.accountUri}`, + + /** + * Text version + */ + text: `Hi, + +We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy. + +This account has been set to be deleted at ${data.dateOfRemoval}. + +${data.supportEmail ? `Please contact ${data.supportEmail} if you want to move your account.` : ''}`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy.

+ +

This account has been set to be deleted at ${data.dateOfRemoval}.

+ +${data.supportEmail ? `

Please contact ${data.supportEmail} if you want to move your account.

` : ''}` + } +} diff --git a/default-templates/emails/reset-password.mjs b/default-templates/emails/reset-password.js similarity index 96% rename from default-templates/emails/reset-password.mjs rename to default-templates/emails/reset-password.js index 8c76e240e..ba002c026 100644 --- a/default-templates/emails/reset-password.mjs +++ b/default-templates/emails/reset-password.js @@ -1,31 +1,31 @@ -export function render (data) { - return { - subject: 'Account password reset', - - /** - * Text version - */ - text: `Hi, - -We received a request to reset your password for your Solid account, ${data.webId} - -To reset your password, click on the following link: - -${data.resetUrl} - -If you did not mean to reset your password, ignore this email, your password will not change.`, - - /** - * HTML version - */ - html: `

Hi,

- -

We received a request to reset your password for your Solid account, ${data.webId}

- -

To reset your password, click on the following link:

- -

${data.resetUrl}

- -

If you did not mean to reset your password, ignore this email, your password will not change.

` - } -} +export function render (data) { + return { + subject: 'Account password reset', + + /** + * Text version + */ + text: `Hi, + +We received a request to reset your password for your Solid account, ${data.webId} + +To reset your password, click on the following link: + +${data.resetUrl} + +If you did not mean to reset your password, ignore this email, your password will not change.`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We received a request to reset your password for your Solid account, ${data.webId}

+ +

To reset your password, click on the following link:

+ +

${data.resetUrl}

+ +

If you did not mean to reset your password, ignore this email, your password will not change.

` + } +} diff --git a/test/resources/config/templates/emails/welcome.mjs b/default-templates/emails/welcome.js similarity index 94% rename from test/resources/config/templates/emails/welcome.mjs rename to default-templates/emails/welcome.js index eec8581e0..8edb1c740 100644 --- a/test/resources/config/templates/emails/welcome.mjs +++ b/default-templates/emails/welcome.js @@ -1,23 +1,23 @@ -export function render (data) { - return { - subject: 'Welcome to Solid', - - /** - * Text version of the Welcome email - */ - text: `Welcome to Solid! - -Your account has been created. - -Your Web Id: ${data.webid}`, - - /** - * HTML version of the Welcome email - */ - html: `

Welcome to Solid!

- -

Your account has been created.

- -

Your Web Id: ${data.webid}

` - } -} +export function render (data) { + return { + subject: 'Welcome to Solid', + + /** + * Text version of the Welcome email + */ + text: `Welcome to Solid! + +Your account has been created. + +Your Web Id: ${data.webid}`, + + /** + * HTML version of the Welcome email + */ + html: `

Welcome to Solid!

+ +

Your account has been created.

+ +

Your Web Id: ${data.webid}

` + } +} diff --git a/default-templates/server/index.html b/default-templates/server/index.html index 85158e1e3..907ef6ac4 100644 --- a/default-templates/server/index.html +++ b/default-templates/server/index.html @@ -48,7 +48,7 @@

Server info

- + diff --git a/eslint.config.mjs b/eslint.config.js similarity index 96% rename from eslint.config.mjs rename to eslint.config.js index be92f658f..f6c07366b 100644 --- a/eslint.config.mjs +++ b/eslint.config.js @@ -1,102 +1,102 @@ -import js from '@eslint/js' -import globals from 'globals' - -export default [ - js.configs.recommended, - { - languageOptions: { - ecmaVersion: 2022, - sourceType: 'module', - globals: { - ...globals.node, - ...globals.mocha, - fetch: 'readonly', - AbortController: 'readonly', - Headers: 'readonly', - Request: 'readonly', - Response: 'readonly', - URL: 'readonly', - URLSearchParams: 'readonly' - } - }, - rules: { - // StandardJS-like rules - 'no-unused-vars': ['warn', { - args: 'none', - caughtErrors: 'none', - ignoreRestSiblings: true, - vars: 'all' - }], - 'no-empty': ['error', { allowEmptyCatch: true }], - 'no-var': 'error', - 'prefer-const': ['error', { destructuring: 'all' }], - 'quote-props': ['error', 'as-needed'], - semi: ['error', 'never'], - quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }], - 'comma-dangle': ['error', 'never'], - 'space-before-function-paren': ['error', 'always'], - indent: ['error', 2, { - SwitchCase: 1, - VariableDeclarator: 1, - outerIIFEBody: 1, - MemberExpression: 1, - FunctionDeclaration: { parameters: 1, body: 1 }, - FunctionExpression: { parameters: 1, body: 1 }, - CallExpression: { arguments: 1 }, - ArrayExpression: 1, - ObjectExpression: 1, - ImportDeclaration: 1, - flatTernaryExpressions: false, - ignoreComments: false, - ignoredNodes: ['TemplateLiteral *', 'JSXElement', 'JSXElement > *', 'JSXAttribute', 'JSXIdentifier', 'JSXNamespacedName', 'JSXMemberExpression', 'JSXSpreadAttribute', 'JSXExpressionContainer', 'JSXOpeningElement', 'JSXClosingElement', 'JSXFragment', 'JSXOpeningFragment', 'JSXClosingFragment', 'JSXText', 'JSXEmptyExpression', 'JSXSpreadChild'], - offsetTernaryExpressions: true - }], - 'key-spacing': ['error', { beforeColon: false, afterColon: true }], - 'keyword-spacing': ['error', { before: true, after: true }], - 'object-curly-spacing': ['error', 'always'], - 'array-bracket-spacing': ['error', 'never'], - 'space-in-parens': ['error', 'never'], - 'space-before-blocks': ['error', 'always'], - 'space-infix-ops': 'error', - 'eol-last': 'error', - 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 0 }], - 'no-trailing-spaces': 'error', - 'comma-spacing': ['error', { before: false, after: true }], - 'no-multi-spaces': 'error', - 'no-mixed-operators': ['error', { - groups: [ - ['==', '!=', '===', '!==', '>', '>=', '<', '<='], - ['&&', '||'], - ['in', 'instanceof'] - ], - allowSamePrecedence: true - }], - 'operator-linebreak': ['error', 'after', { overrides: { '?': 'before', ':': 'before', '|>': 'before' } }], - 'brace-style': ['error', '1tbs', { allowSingleLine: true }], - 'arrow-spacing': ['error', { before: true, after: true }], - 'padded-blocks': ['error', { blocks: 'never', switches: 'never', classes: 'never' }], - 'no-use-before-define': ['error', { functions: false, classes: false, variables: false }] - } - }, - { - // Browser files (client-side code) - files: ['common/**/*.mjs'], - languageOptions: { - globals: { - ...globals.browser, - solid: 'readonly', - UI: 'readonly', - owaspPasswordStrengthTest: 'readonly' - } - } - }, - { - ignores: [ - 'node_modules/**', - 'coverage/**', - '.db/**', - 'data/**', - 'resources/**' - ] - } -] +import js from '@eslint/js' +import globals from 'globals' + +export default [ + js.configs.recommended, + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + ...globals.node, + ...globals.mocha, + fetch: 'readonly', + AbortController: 'readonly', + Headers: 'readonly', + Request: 'readonly', + Response: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly' + } + }, + rules: { + // StandardJS-like rules + 'no-unused-vars': ['warn', { + args: 'none', + caughtErrors: 'none', + ignoreRestSiblings: true, + vars: 'all' + }], + 'no-empty': ['error', { allowEmptyCatch: true }], + 'no-var': 'error', + 'prefer-const': ['error', { destructuring: 'all' }], + 'quote-props': ['error', 'as-needed'], + semi: ['error', 'never'], + quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }], + 'comma-dangle': ['error', 'never'], + 'space-before-function-paren': ['error', 'always'], + indent: ['error', 2, { + SwitchCase: 1, + VariableDeclarator: 1, + outerIIFEBody: 1, + MemberExpression: 1, + FunctionDeclaration: { parameters: 1, body: 1 }, + FunctionExpression: { parameters: 1, body: 1 }, + CallExpression: { arguments: 1 }, + ArrayExpression: 1, + ObjectExpression: 1, + ImportDeclaration: 1, + flatTernaryExpressions: false, + ignoreComments: false, + ignoredNodes: ['TemplateLiteral *', 'JSXElement', 'JSXElement > *', 'JSXAttribute', 'JSXIdentifier', 'JSXNamespacedName', 'JSXMemberExpression', 'JSXSpreadAttribute', 'JSXExpressionContainer', 'JSXOpeningElement', 'JSXClosingElement', 'JSXFragment', 'JSXOpeningFragment', 'JSXClosingFragment', 'JSXText', 'JSXEmptyExpression', 'JSXSpreadChild'], + offsetTernaryExpressions: true + }], + 'key-spacing': ['error', { beforeColon: false, afterColon: true }], + 'keyword-spacing': ['error', { before: true, after: true }], + 'object-curly-spacing': ['error', 'always'], + 'array-bracket-spacing': ['error', 'never'], + 'space-in-parens': ['error', 'never'], + 'space-before-blocks': ['error', 'always'], + 'space-infix-ops': 'error', + 'eol-last': 'error', + 'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 0 }], + 'no-trailing-spaces': 'error', + 'comma-spacing': ['error', { before: false, after: true }], + 'no-multi-spaces': 'error', + 'no-mixed-operators': ['error', { + groups: [ + ['==', '!=', '===', '!==', '>', '>=', '<', '<='], + ['&&', '||'], + ['in', 'instanceof'] + ], + allowSamePrecedence: true + }], + 'operator-linebreak': ['error', 'after', { overrides: { '?': 'before', ':': 'before', '|>': 'before' } }], + 'brace-style': ['error', '1tbs', { allowSingleLine: true }], + 'arrow-spacing': ['error', { before: true, after: true }], + 'padded-blocks': ['error', { blocks: 'never', switches: 'never', classes: 'never' }], + 'no-use-before-define': ['error', { functions: false, classes: false, variables: false }] + } + }, + { + // Browser files (client-side code) + files: ['common/**/*.js'], + languageOptions: { + globals: { + ...globals.browser, + solid: 'readonly', + UI: 'readonly', + owaspPasswordStrengthTest: 'readonly' + } + } + }, + { + ignores: [ + 'node_modules/**', + 'coverage/**', + '.db/**', + 'data/**', + 'resources/**' + ] + } +] diff --git a/examples/custom-error-handling.mjs b/examples/custom-error-handling.cjs similarity index 90% rename from examples/custom-error-handling.mjs rename to examples/custom-error-handling.cjs index eb16ef49b..e790adcb6 100644 --- a/examples/custom-error-handling.mjs +++ b/examples/custom-error-handling.cjs @@ -1,29 +1,31 @@ -import solid from '../index.mjs' -import path from 'path' - -solid - .createServer({ - webid: true, - sslCert: path.resolve('../test/keys/cert.pem'), - sslKey: path.resolve('../test/keys/key.pem'), - errorHandler: function (err, req, res, next) { - if (err.status !== 200) { - console.log('Oh no! There is an error:' + err.message) - res.status(err.status) - // Now you can send the error how you want - // Maybe you want to render an error page - // res.render('errorPage.ejs', { - // title: err.status + ": This is an error!", - // message: err.message - // }) - // Or you want to respond in JSON? - res.json({ - title: err.status + ': This is an error!', - message: err.message - }) - } - } - }) - .listen(3456, function () { - console.log('started ldp with webid on port ' + 3456) - }) +const solid = require('../') +const path = require('path') + +solid + .createServer({ + webid: true, + sslCert: path.resolve('../test/keys/cert.pem'), + sslKey: path.resolve('../test/keys/key.pem'), + errorHandler: function (err, req, res, next) { + if (err.status !== 200) { + console.log('Oh no! There is an error:' + err.message) + res.status(err.status) + + // Now you can send the error how you want + // Maybe you want to render an error page + // res.render('errorPage.ejs', { + // title: err.status + ": This is an error!", + // message: err.message + // }) + // Or you want to respond in JSON? + + res.json({ + title: err.status + ': This is an error!', + message: err.message + }) + } + } + }) + .listen(3456, function () { + console.log('started ldp with webid on port ' + 3456) + }) diff --git a/examples/custom-error-handling.js b/examples/custom-error-handling.js index e790adcb6..a748da4c9 100644 --- a/examples/custom-error-handling.js +++ b/examples/custom-error-handling.js @@ -1,5 +1,5 @@ -const solid = require('../') -const path = require('path') +import solid from '../index.js' +import path from 'path' solid .createServer({ @@ -10,7 +10,6 @@ solid if (err.status !== 200) { console.log('Oh no! There is an error:' + err.message) res.status(err.status) - // Now you can send the error how you want // Maybe you want to render an error page // res.render('errorPage.ejs', { @@ -18,7 +17,6 @@ solid // message: err.message // }) // Or you want to respond in JSON? - res.json({ title: err.status + ': This is an error!', message: err.message diff --git a/examples/ldp-with-webid.mjs b/examples/ldp-with-webid.cjs similarity index 74% rename from examples/ldp-with-webid.mjs rename to examples/ldp-with-webid.cjs index d660c75c0..4f8329f05 100644 --- a/examples/ldp-with-webid.mjs +++ b/examples/ldp-with-webid.cjs @@ -1,12 +1,12 @@ -import solid from '../index.mjs' -import path from 'path' - -solid - .createServer({ - webid: true, - sslCert: path.resolve('../test/keys/cert.pem'), - sslKey: path.resolve('../test/keys/key.pem') - }) - .listen(3456, function () { - console.log('started ldp with webid on port ' + 3456) - }) +const solid = require('../') // or require('solid') +const path = require('path') + +solid + .createServer({ + webid: true, + sslCert: path.resolve('../test/keys/cert.pem'), + sslKey: path.resolve('../test/keys/key.pem') + }) + .listen(3456, function () { + console.log('started ldp with webid on port ' + 3456) + }) diff --git a/examples/ldp-with-webid.js b/examples/ldp-with-webid.js index 4f8329f05..cf242208a 100644 --- a/examples/ldp-with-webid.js +++ b/examples/ldp-with-webid.js @@ -1,5 +1,5 @@ -const solid = require('../') // or require('solid') -const path = require('path') +import solid from '../index.js' +import path from 'path' solid .createServer({ diff --git a/examples/simple-express-app.mjs b/examples/simple-express-app.cjs similarity index 78% rename from examples/simple-express-app.mjs rename to examples/simple-express-app.cjs index 4cc4f31ae..bfdcc352c 100644 --- a/examples/simple-express-app.mjs +++ b/examples/simple-express-app.cjs @@ -1,20 +1,20 @@ -import express from 'express' -import solid from '../index.mjs' - -// Starting our express app -const app = express() - -// My routes -app.get('/', function (req, res) { - console.log(req) - res.send('Welcome to my server!') -}) - -// Mounting solid on /ldp -const ldp = solid() -app.use('/ldp', ldp) - -// Starting server -app.listen(3000, function () { - console.log('Server started on port 3000!') -}) +const express = require('express') +const solid = require('../') // or require('solid') + +// Starting our express app +const app = express() + +// My routes +app.get('/', function (req, res) { + console.log(req) + res.send('Welcome to my server!') +}) + +// Mounting solid on /ldp +const ldp = solid() +app.use('/ldp', ldp) + +// Starting server +app.listen(3000, function () { + console.log('Server started on port 3000!') +}) diff --git a/examples/simple-express-app.js b/examples/simple-express-app.js index bfdcc352c..94522dfc6 100644 --- a/examples/simple-express-app.js +++ b/examples/simple-express-app.js @@ -1,5 +1,5 @@ -const express = require('express') -const solid = require('../') // or require('solid') +import express from 'express' +import solid from '../index.js' // Starting our express app const app = express() diff --git a/examples/simple-ldp-server.mjs b/examples/simple-ldp-server.cjs similarity index 64% rename from examples/simple-ldp-server.mjs rename to examples/simple-ldp-server.cjs index 9ff5a469d..678963446 100644 --- a/examples/simple-ldp-server.mjs +++ b/examples/simple-ldp-server.cjs @@ -1,8 +1,8 @@ -import solid from '../index.mjs' - -// Starting solid server -const ldp = solid.createServer() -ldp.listen(3456, function () { - console.log('Starting server on port ' + 3456) - console.log('LDP will run on /') -}) +const solid = require('../') // or require('solid-server') + +// Startin solid server +const ldp = solid.createServer() +ldp.listen(3456, function () { + console.log('Starting server on port ' + 3456) + console.log('LDP will run on /') +}) diff --git a/examples/simple-ldp-server.js b/examples/simple-ldp-server.js index 678963446..f264b5923 100644 --- a/examples/simple-ldp-server.js +++ b/examples/simple-ldp-server.js @@ -1,6 +1,6 @@ -const solid = require('../') // or require('solid-server') +import solid from '../index.js' -// Startin solid server +// Starting solid server const ldp = solid.createServer() ldp.listen(3456, function () { console.log('Starting server on port ' + 3456) diff --git a/index.mjs b/index.js similarity index 81% rename from index.mjs rename to index.js index 5d5fe5ffe..f74e7b231 100644 --- a/index.mjs +++ b/index.js @@ -1,23 +1,23 @@ -import createServer from './lib/create-server.mjs' -import ldnode from './lib/create-app.mjs' -import startCli from './bin/lib/cli.mjs' - -// Preserve the CommonJS-style shape where the default export has -// `createServer` and `startCli` attached as properties so existing -// tests that call `ldnode.createServer()` continue to work. -let exported -const canAttach = (ldnode && (typeof ldnode === 'object' || typeof ldnode === 'function')) -if (canAttach) { - try { - if (!ldnode.createServer) ldnode.createServer = createServer - if (!ldnode.startCli) ldnode.startCli = startCli - exported = ldnode - } catch (e) { - exported = { default: ldnode, createServer, startCli } - } -} else { - exported = { default: ldnode, createServer, startCli } -} - -export default exported -export { createServer, startCli } +import createServer from './lib/create-server.js' +import ldnode from './lib/create-app.js' +import startCli from './bin/lib/cli.js' + +// Preserve the CommonJS-style shape where the default export has +// `createServer` and `startCli` attached as properties so existing +// tests that call `ldnode.createServer()` continue to work. +let exported +const canAttach = (ldnode && (typeof ldnode === 'object' || typeof ldnode === 'function')) +if (canAttach) { + try { + if (!ldnode.createServer) ldnode.createServer = createServer + if (!ldnode.startCli) ldnode.startCli = startCli + exported = ldnode + } catch (e) { + exported = { default: ldnode, createServer, startCli } + } +} else { + exported = { default: ldnode, createServer, startCli } +} + +export default exported +export { createServer, startCli } diff --git a/lib/acl-checker.mjs b/lib/acl-checker.js similarity index 99% rename from lib/acl-checker.mjs rename to lib/acl-checker.js index 2aee192e9..102fe5004 100644 --- a/lib/acl-checker.mjs +++ b/lib/acl-checker.js @@ -2,9 +2,9 @@ import { dirname } from 'path' import rdf from 'rdflib' -import { ACL as debug } from './debug.mjs' -// import { cache as debugCache } from './debug.mjs' -import HTTPError from './http-error.mjs' +import { ACL as debug } from './debug.js' +// import { cache as debugCache } from './debug.js' +import HTTPError from './http-error.js' import aclCheck from '@solid/acl-check' import Url, { URL } from 'url' import { promisify } from 'util' diff --git a/lib/api/accounts/user-accounts.mjs b/lib/api/accounts/user-accounts.js similarity index 95% rename from lib/api/accounts/user-accounts.mjs rename to lib/api/accounts/user-accounts.js index a44b9b79f..bf95ebc28 100644 --- a/lib/api/accounts/user-accounts.mjs +++ b/lib/api/accounts/user-accounts.js @@ -1,13 +1,13 @@ import express from 'express' import bodyParserPkg from 'body-parser' -import debug from '../../debug.mjs' +import debug from '../../debug.js' -import restrictToTopDomain from '../../handlers/restrict-to-top-domain.mjs' +import restrictToTopDomain from '../../handlers/restrict-to-top-domain.js' -import { CreateAccountRequest } from '../../requests/create-account-request.mjs' -import AddCertificateRequest from '../../requests/add-cert-request.mjs' -import DeleteAccountRequest from '../../requests/delete-account-request.mjs' -import DeleteAccountConfirmRequest from '../../requests/delete-account-confirm-request.mjs' +import { CreateAccountRequest } from '../../requests/create-account-request.js' +import AddCertificateRequest from '../../requests/add-cert-request.js' +import DeleteAccountRequest from '../../requests/delete-account-request.js' +import DeleteAccountConfirmRequest from '../../requests/delete-account-confirm-request.js' const { urlencoded } = bodyParserPkg const bodyParser = urlencoded({ extended: false }) const debugAccounts = debug.accounts diff --git a/lib/api/authn/force-user.mjs b/lib/api/authn/force-user.js similarity index 93% rename from lib/api/authn/force-user.mjs rename to lib/api/authn/force-user.js index 642dfd75e..627633142 100644 --- a/lib/api/authn/force-user.mjs +++ b/lib/api/authn/force-user.js @@ -1,4 +1,4 @@ -import debug from '../../debug.mjs' +import debug from '../../debug.js' const debugAuth = debug.authentication /** diff --git a/lib/api/authn/index.mjs b/lib/api/authn/index.js similarity index 50% rename from lib/api/authn/index.mjs rename to lib/api/authn/index.js index 93e1108ea..87e92ff5a 100644 --- a/lib/api/authn/index.mjs +++ b/lib/api/authn/index.js @@ -1,8 +1,8 @@ -import oidc from './webid-oidc.mjs' -import tls from './webid-tls.mjs' -import forceUser from './force-user.mjs' +import oidc from './webid-oidc.js' +import tls from './webid-tls.js' +import forceUser from './force-user.js' export { oidc, tls, forceUser } -// Provide a default export so callers can `import Auth from './lib/api/authn/index.mjs'` +// Provide a default export so callers can `import Auth from './lib/api/authn/index.js'` export default { oidc, tls, forceUser } diff --git a/lib/api/authn/webid-oidc.mjs b/lib/api/authn/webid-oidc.js similarity index 95% rename from lib/api/authn/webid-oidc.mjs rename to lib/api/authn/webid-oidc.js index 593c971cc..148d9b3a9 100644 --- a/lib/api/authn/webid-oidc.mjs +++ b/lib/api/authn/webid-oidc.js @@ -3,16 +3,16 @@ */ import express from 'express' -import { routeResolvedFile } from '../../utils.mjs' +import { routeResolvedFile } from '../../utils.js' import bodyParserPkg from 'body-parser' -import { fromServerConfig } from '../../models/oidc-manager.mjs' -import { LoginRequest } from '../../requests/login-request.mjs' -import { SharingRequest } from '../../requests/sharing-request.mjs' +import { fromServerConfig } from '../../models/oidc-manager.js' +import { LoginRequest } from '../../requests/login-request.js' +import { SharingRequest } from '../../requests/sharing-request.js' -import restrictToTopDomain from '../../handlers/restrict-to-top-domain.mjs' +import restrictToTopDomain from '../../handlers/restrict-to-top-domain.js' -import PasswordResetEmailRequest from '../../requests/password-reset-email-request.mjs' -import PasswordChangeRequest from '../../requests/password-change-request.mjs' +import PasswordResetEmailRequest from '../../requests/password-reset-email-request.js' +import PasswordChangeRequest from '../../requests/password-change-request.js' import oidcOpExpress from 'oidc-op-express' diff --git a/lib/api/authn/webid-tls.mjs b/lib/api/authn/webid-tls.js similarity index 95% rename from lib/api/authn/webid-tls.mjs rename to lib/api/authn/webid-tls.js index b4d4a67b8..481daf0ac 100644 --- a/lib/api/authn/webid-tls.mjs +++ b/lib/api/authn/webid-tls.js @@ -1,5 +1,5 @@ -import * as webid from '../../webid/tls/index.mjs' -import debug from '../../debug.mjs' +import * as webid from '../../webid/tls/index.js' +import debug from '../../debug.js' const debugAuth = debug.authentication export function initialize (app, argv) { diff --git a/lib/api/index.mjs b/lib/api/index.js similarity index 54% rename from lib/api/index.mjs rename to lib/api/index.js index 0080af3f5..7fadab747 100644 --- a/lib/api/index.mjs +++ b/lib/api/index.js @@ -1,7 +1,7 @@ -import authn from './authn/index.mjs' -import accounts from './accounts/user-accounts.mjs' +import authn from './authn/index.js' +import accounts from './accounts/user-accounts.js' export { authn, accounts } -// Provide a default export so callers can `import API from './lib/api/index.mjs'` +// Provide a default export so callers can `import API from './lib/api/index.js'` export default { authn, accounts } diff --git a/lib/capability-discovery.mjs b/lib/capability-discovery.js similarity index 100% rename from lib/capability-discovery.mjs rename to lib/capability-discovery.js diff --git a/lib/common/fs-utils.mjs b/lib/common/fs-utils.js similarity index 96% rename from lib/common/fs-utils.mjs rename to lib/common/fs-utils.js index 444dcbac5..13ba7b874 100644 --- a/lib/common/fs-utils.mjs +++ b/lib/common/fs-utils.js @@ -1,35 +1,35 @@ -import fs from 'fs-extra' - -export async function copyTemplateDir (templatePath, targetPath) { - return new Promise((resolve, reject) => { - fs.copy(templatePath, targetPath, (error) => { - if (error) { return reject(error) } - resolve() - }) - }) -} - -export async function processFile (filePath, manipulateSourceFn) { - return new Promise((resolve, reject) => { - fs.readFile(filePath, 'utf8', (error, rawSource) => { - if (error) { - return reject(error) - } - const output = manipulateSourceFn(rawSource) - fs.writeFile(filePath, output, (error) => { - if (error) { - return reject(error) - } - resolve() - }) - }) - }) -} - -export function readFile (filePath, options = 'utf-8') { - return fs.readFileSync(filePath, options) -} - -export function writeFile (filePath, fileSource, options = 'utf-8') { - fs.writeFileSync(filePath, fileSource, options) -} +import fs from 'fs-extra' + +export async function copyTemplateDir (templatePath, targetPath) { + return new Promise((resolve, reject) => { + fs.copy(templatePath, targetPath, (error) => { + if (error) { return reject(error) } + resolve() + }) + }) +} + +export async function processFile (filePath, manipulateSourceFn) { + return new Promise((resolve, reject) => { + fs.readFile(filePath, 'utf8', (error, rawSource) => { + if (error) { + return reject(error) + } + const output = manipulateSourceFn(rawSource) + fs.writeFile(filePath, output, (error) => { + if (error) { + return reject(error) + } + resolve() + }) + }) + }) +} + +export function readFile (filePath, options = 'utf-8') { + return fs.readFileSync(filePath, options) +} + +export function writeFile (filePath, fileSource, options = 'utf-8') { + fs.writeFileSync(filePath, fileSource, options) +} diff --git a/lib/common/template-utils.mjs b/lib/common/template-utils.js similarity index 85% rename from lib/common/template-utils.mjs rename to lib/common/template-utils.js index 4c6bb7af7..b6e2ac7f4 100644 --- a/lib/common/template-utils.mjs +++ b/lib/common/template-utils.js @@ -1,29 +1,29 @@ -import Handlebars from 'handlebars' -import debugModule from '../debug.mjs' -import { processFile, readFile, writeFile } from './fs-utils.mjs' - -const debug = debugModule.errors - -export async function compileTemplate (filePath) { - const indexTemplateSource = readFile(filePath) - return Handlebars.compile(indexTemplateSource) -} - -export async function processHandlebarFile (filePath, substitutions) { - return processFile(filePath, (rawSource) => processHandlebarTemplate(rawSource, substitutions)) -} - -function processHandlebarTemplate (source, substitutions) { - try { - const template = Handlebars.compile(source) - return template(substitutions) - } catch (error) { - debug(`Error processing template: ${error}`) - return source - } -} - -export function writeTemplate (filePath, template, substitutions) { - const source = template(substitutions) - writeFile(filePath, source) -} +import Handlebars from 'handlebars' +import debugModule from '../debug.js' +import { processFile, readFile, writeFile } from './fs-utils.js' + +const debug = debugModule.errors + +export async function compileTemplate (filePath) { + const indexTemplateSource = readFile(filePath) + return Handlebars.compile(indexTemplateSource) +} + +export async function processHandlebarFile (filePath, substitutions) { + return processFile(filePath, (rawSource) => processHandlebarTemplate(rawSource, substitutions)) +} + +function processHandlebarTemplate (source, substitutions) { + try { + const template = Handlebars.compile(source) + return template(substitutions) + } catch (error) { + debug(`Error processing template: ${error}`) + return source + } +} + +export function writeTemplate (filePath, template, substitutions) { + const source = template(substitutions) + writeFile(filePath, source) +} diff --git a/lib/common/user-utils.mjs b/lib/common/user-utils.js similarity index 97% rename from lib/common/user-utils.mjs rename to lib/common/user-utils.js index e903b17ee..938cf90cf 100644 --- a/lib/common/user-utils.mjs +++ b/lib/common/user-utils.js @@ -1,24 +1,24 @@ -import $rdf from 'rdflib' - -const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#') -const VCARD = $rdf.Namespace('http://www.w3.org/2006/vcard/ns#') - -export async function getName (webId, fetchGraph) { - const graph = await fetchGraph(webId) - const nameNode = graph.any($rdf.sym(webId), VCARD('fn')) - return nameNode.value -} - -export async function getWebId (accountDirectory, accountUrl, suffixMeta, fetchData) { - const metaFilePath = `${accountDirectory}/${suffixMeta}` - const metaFileUri = `${accountUrl}${suffixMeta}` - const metaData = await fetchData(metaFilePath) - const metaGraph = $rdf.graph() - $rdf.parse(metaData, metaGraph, metaFileUri, 'text/turtle') - const webIdNode = metaGraph.any(undefined, SOLID('account'), $rdf.sym(accountUrl)) - return webIdNode.value -} - -export function isValidUsername (username) { - return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(username) -} +import $rdf from 'rdflib' + +const SOLID = $rdf.Namespace('http://www.w3.org/ns/solid/terms#') +const VCARD = $rdf.Namespace('http://www.w3.org/2006/vcard/ns#') + +export async function getName (webId, fetchGraph) { + const graph = await fetchGraph(webId) + const nameNode = graph.any($rdf.sym(webId), VCARD('fn')) + return nameNode.value +} + +export async function getWebId (accountDirectory, accountUrl, suffixMeta, fetchData) { + const metaFilePath = `${accountDirectory}/${suffixMeta}` + const metaFileUri = `${accountUrl}${suffixMeta}` + const metaData = await fetchData(metaFilePath) + const metaGraph = $rdf.graph() + $rdf.parse(metaData, metaGraph, metaFileUri, 'text/turtle') + const webIdNode = metaGraph.any(undefined, SOLID('account'), $rdf.sym(accountUrl)) + return webIdNode.value +} + +export function isValidUsername (username) { + return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(username) +} diff --git a/lib/create-app.mjs b/lib/create-app.js similarity index 90% rename from lib/create-app.mjs rename to lib/create-app.js index 23c7ec03a..1ad71afcd 100644 --- a/lib/create-app.mjs +++ b/lib/create-app.js @@ -1,372 +1,372 @@ -import express from 'express' -import session from 'express-session' -import handlebars from 'express-handlebars' -import { v4 as uuid } from 'uuid' -import cors from 'cors' -import vhost from 'vhost' -import path, { dirname } from 'path' -import aclCheck from '@solid/acl-check' -import fs from 'fs' -import { fileURLToPath } from 'url' - -import acceptEvents from 'express-accept-events' -import events from 'express-negotiate-events' -import eventID from 'express-prep/event-id' -import prep from 'express-prep' - -// Complex internal modules - keep as CommonJS for now except where ESM available -import LDP from './ldp.mjs' -import LdpMiddleware from './ldp-middleware.mjs' -import corsProxy from './handlers/cors-proxy.mjs' -import authProxy from './handlers/auth-proxy.mjs' -import SolidHost from './models/solid-host.mjs' -import AccountManager from './models/account-manager.mjs' -import EmailService from './services/email-service.mjs' -import TokenService from './services/token-service.mjs' -import capabilityDiscovery from './capability-discovery.mjs' -import paymentPointerDiscovery from './payment-pointer-discovery.mjs' -import * as API from './api/index.mjs' -import errorPages from './handlers/error-pages.mjs' -import * as config from './server-config.mjs' -import defaults from '../config/defaults.mjs' -import options from './handlers/options.mjs' -import debug from './debug.mjs' -import { routeResolvedFile } from './utils.mjs' -import ResourceMapper from './resource-mapper.mjs' - -// ESM equivalents of __filename and __dirname -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) - -// Read package.json synchronously to avoid using require() for JSON -const { version } = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')) - -const corsSettings = cors({ - methods: [ - 'OPTIONS', 'HEAD', 'GET', 'PATCH', 'POST', 'PUT', 'DELETE' - ], - exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Accept-Put, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate, MS-Author-Via, X-Powered-By', - credentials: true, - maxAge: 1728000, - origin: true, - preflightContinue: true -}) - -function createApp (argv = {}) { - // Override default configs (defaults) with passed-in params (argv) - argv = Object.assign({}, defaults, argv) - - argv.host = SolidHost.from(argv) - - argv.resourceMapper = new ResourceMapper({ - rootUrl: argv.serverUri, - rootPath: path.resolve(argv.root || process.cwd()), - includeHost: argv.multiuser, - defaultContentType: argv.defaultContentType - }) - - const configPath = config.initConfigDir(argv) - argv.templates = config.initTemplateDirs(configPath) - - config.printDebugInfo(argv) - - const ldp = new LDP(argv) - - const app = express() - - // Add PREP support - if (argv.prep) { - app.use(eventID) - app.use(acceptEvents, events, prep) - } - - initAppLocals(app, argv, ldp) - initHeaders(app) - initViews(app, configPath) - initLoggers() - - // Serve the public 'common' directory (for shared CSS files, etc) - app.use('/common', express.static(path.join(__dirname, '../common'))) - app.use('/', express.static(path.dirname(fileURLToPath(import.meta.resolve('mashlib/dist/databrowser.html'))), { index: false })) - routeResolvedFile(app, '/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js') - routeResolvedFile(app, '/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js.map') - app.use('/.well-known', express.static(path.join(__dirname, '../common/well-known'))) - - // Serve bootstrap from it's node_module directory - routeResolvedFile(app, '/common/css/', 'bootstrap/dist/css/bootstrap.min.css') - routeResolvedFile(app, '/common/css/', 'bootstrap/dist/css/bootstrap.min.css.map') - routeResolvedFile(app, '/common/fonts/', 'bootstrap/dist/fonts/glyphicons-halflings-regular.eot') - routeResolvedFile(app, '/common/fonts/', 'bootstrap/dist/fonts/glyphicons-halflings-regular.svg') - routeResolvedFile(app, '/common/fonts/', 'bootstrap/dist/fonts/glyphicons-halflings-regular.ttf') - routeResolvedFile(app, '/common/fonts/', 'bootstrap/dist/fonts/glyphicons-halflings-regular.woff') - routeResolvedFile(app, '/common/fonts/', 'bootstrap/dist/fonts/glyphicons-halflings-regular.woff2') - - // Serve OWASP password checker from it's node_module directory - routeResolvedFile(app, '/common/js/', 'owasp-password-strength-test/owasp-password-strength-test.js') - // Serve the TextEncoder polyfill - routeResolvedFile(app, '/common/js/', 'text-encoder-lite/text-encoder-lite.min.js') - - // Add CORS proxy - if (argv.proxy) { - console.warn('The proxy configuration option has been renamed to corsProxy.') - argv.corsProxy = argv.corsProxy || argv.proxy - delete argv.proxy - } - if (argv.corsProxy) { - corsProxy(app, argv.corsProxy) - } - - // Options handler - app.options('/*', options) - - // Set up API - if (argv.apiApps) { - app.use('/api/apps', express.static(argv.apiApps)) - } - - // Authenticate the user - if (argv.webid) { - initWebId(argv, app, ldp) - } - // Add Auth proxy (requires authentication) - if (argv.authProxy) { - authProxy(app, argv.authProxy) - } - - // Attach the LDP middleware - app.use('/', LdpMiddleware(corsSettings, argv.prep)) - - // https://stackoverflow.com/questions/51741383/nodejs-express-return-405-for-un-supported-method - app.use(function (req, res, next) { - const AllLayers = app._router.stack - const Layers = AllLayers.filter(x => x.name === 'bound dispatch' && x.regexp.test(req.path)) - - const Methods = [] - Layers.forEach(layer => { - for (const method in layer.route.methods) { - if (layer.route.methods[method] === true) { - Methods.push(method.toUpperCase()) - } - } - }) - - if (Layers.length !== 0 && !Methods.includes(req.method)) { - // res.setHeader('Allow', Methods.join(',')) - - if (req.method === 'OPTIONS') { - return res.send(Methods.join(', ')) - } else { - return res.status(405).send() - } - } else { - next() - } - }) - - // Errors - app.use(errorPages.handler) - - return app -} - -/** - * Initializes `app.locals` parameters for downstream use (typically by route - * handlers). - * - * @param app {Function} Express.js app instance - * @param argv {Object} Config options hashmap - * @param ldp {LDP} - */ -function initAppLocals (app, argv, ldp) { - app.locals.ldp = ldp - app.locals.appUrls = argv.apps // used for service capability discovery - app.locals.host = argv.host - app.locals.authMethod = argv.auth - app.locals.localAuth = argv.localAuth - app.locals.tokenService = new TokenService() - app.locals.enforceToc = argv.enforceToc - app.locals.tocUri = argv.tocUri - app.locals.disablePasswordChecks = argv.disablePasswordChecks - app.locals.prep = argv.prep - - if (argv.email && argv.email.host) { - app.locals.emailService = new EmailService(argv.templates.email, argv.email) - } -} - -/** - * Sets up headers common to all Solid requests (CORS-related, Allow, etc). - * - * @param app {Function} Express.js app instance - */ -function initHeaders (app) { - app.use(corsSettings) - - app.use((req, res, next) => { - res.set('X-Powered-By', 'solid-server/' + version) - - // Cors lib adds Vary: Origin automatically, but inreliably - res.set('Vary', 'Accept, Authorization, Origin') - - // Set default Allow methods - res.set('Allow', 'OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE') - next() - }) - - app.use('/', capabilityDiscovery()) - app.use('/', paymentPointerDiscovery()) -} - -/** - * Sets up the express rendering engine and views directory. - * - * @param app {Function} Express.js app - * @param configPath {string} - */ -function initViews (app, configPath) { - const viewsPath = config.initDefaultViews(configPath) - - app.set('views', viewsPath) - app.engine('.hbs', handlebars({ - extname: '.hbs', - partialsDir: viewsPath, - defaultLayout: null - })) - app.set('view engine', '.hbs') -} - -/** - * Sets up WebID-related functionality (account creation and authentication) - * - * @param argv {Object} - * @param app {Function} - * @param ldp {LDP} - */ -function initWebId (argv, app, ldp) { - config.ensureWelcomePage(argv) - - // Store the user's session key in a cookie - // (for same-domain browsing by people only) - const useSecureCookies = !!argv.sslKey // use secure cookies when over HTTPS - const sessionHandler = session(sessionSettings(useSecureCookies, argv.host)) - app.use(sessionHandler) - // Reject cookies from third-party applications. - // Otherwise, when a user is logged in to their Solid server, - // any third-party application could perform authenticated requests - // without permission by including the credentials set by the Solid server. - app.use((req, res, next) => { - const origin = req.get('origin') - const trustedOrigins = ldp.getTrustedOrigins(req) - const userId = req.session.userId - // Exception: allow logout requests from all third-party apps - // such that OIDC client can log out via cookie auth - // TODO: remove this exception when OIDC clients - // use Bearer token to authenticate instead of cookie - // (https://github.com/solid/node-solid-server/pull/835#issuecomment-426429003) - // - // Authentication cookies are an optimization: - // instead of going through the process of - // fully validating authentication on every request, - // we go through this process once, - // and store its successful result in a cookie - // that will be reused upon the next request. - // However, that cookie can then be sent by any server, - // even servers that have not gone through the proper authentication mechanism. - // However, if trusted origins are enabled, - // then any origin is allowed to take the shortcut route, - // since malicious origins will be banned at the ACL checking phase. - // https://github.com/solid/node-solid-server/issues/1117 - if (!argv.strictOrigin && !argv.host.allowsSessionFor(userId, origin, trustedOrigins) && !isLogoutRequest(req)) { - debug.authentication(`Rejecting session for ${userId} from ${origin}`) - // Destroy session data - delete req.session.userId - // Ensure this modified session is not saved - req.session.save = (done) => done() - } - if (isLogoutRequest(req)) { - delete req.session.userId - } - next() - }) - - const accountManager = AccountManager.from({ - authMethod: argv.auth, - emailService: app.locals.emailService, - tokenService: app.locals.tokenService, - host: argv.host, - accountTemplatePath: argv.templates.account, - store: ldp, - multiuser: argv.multiuser - }) - app.locals.accountManager = accountManager - - // Account Management API (create account, new cert) - app.use('/', API.accounts.middleware(accountManager)) - - // Set up authentication-related API endpoints and app.locals - initAuthentication(app, argv) - - if (argv.multiuser) { - app.use(vhost('*', LdpMiddleware(corsSettings, argv.prep))) - } -} - -function initLoggers () { - aclCheck.configureLogger(debug.ACL) -} - -/** - * Determines whether the given request is a logout request - */ -function isLogoutRequest (req) { - // TODO: this is a hack that hard-codes OIDC paths, - // this code should live in the OIDC module - return req.path === '/logout' || req.path === '/goodbye' -} - -/** - * Sets up authentication-related routes and handlers for the app. - * - * @param app {Object} Express.js app instance - * @param argv {Object} Config options hashmap - * @return {Promise} Resolves when authentication initialization is complete - */ -async function initAuthentication (app, argv) { - const auth = argv.forceUser ? 'forceUser' : argv.auth - if (!(auth in API.authn)) { - throw new Error(`Unsupported authentication scheme: ${auth}`) - } - await API.authn[auth].initialize(app, argv) -} - -/** - * Returns a settings object for Express.js sessions. - * - * @param secureCookies {boolean} - * @param host {SolidHost} - * - * @return {Object} `express-session` settings object - */ -function sessionSettings (secureCookies, host) { - const sessionSettings = { - name: 'nssidp.sid', - secret: uuid(), - saveUninitialized: false, - resave: false, - rolling: true, - cookie: { - maxAge: 24 * 60 * 60 * 1000 - } - } - // Cookies should set to be secure if https is on - if (secureCookies) { - sessionSettings.cookie.secure = true - } - - // Determine the cookie domain - sessionSettings.cookie.domain = host.cookieDomain - - return sessionSettings -} - -export default createApp +import express from 'express' +import session from 'express-session' +import handlebars from 'express-handlebars' +import { v4 as uuid } from 'uuid' +import cors from 'cors' +import vhost from 'vhost' +import path, { dirname } from 'path' +import aclCheck from '@solid/acl-check' +import fs from 'fs' +import { fileURLToPath } from 'url' + +import acceptEvents from 'express-accept-events' +import events from 'express-negotiate-events' +import eventID from 'express-prep/event-id' +import prep from 'express-prep' + +// Complex internal modules - keep as CommonJS for now except where ESM available +import LDP from './ldp.js' +import LdpMiddleware from './ldp-middleware.js' +import corsProxy from './handlers/cors-proxy.js' +import authProxy from './handlers/auth-proxy.js' +import SolidHost from './models/solid-host.js' +import AccountManager from './models/account-manager.js' +import EmailService from './services/email-service.js' +import TokenService from './services/token-service.js' +import capabilityDiscovery from './capability-discovery.js' +import paymentPointerDiscovery from './payment-pointer-discovery.js' +import * as API from './api/index.js' +import errorPages from './handlers/error-pages.js' +import * as config from './server-config.js' +import defaults from '../config/defaults.js' +import options from './handlers/options.js' +import debug from './debug.js' +import { routeResolvedFile } from './utils.js' +import ResourceMapper from './resource-mapper.js' + +// ESM equivalents of __filename and __dirname +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +// Read package.json synchronously to avoid using require() for JSON +const { version } = JSON.parse(fs.readFileSync(path.join(__dirname, '../package.json'), 'utf8')) + +const corsSettings = cors({ + methods: [ + 'OPTIONS', 'HEAD', 'GET', 'PATCH', 'POST', 'PUT', 'DELETE' + ], + exposedHeaders: 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Accept-Put, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate, MS-Author-Via, X-Powered-By', + credentials: true, + maxAge: 1728000, + origin: true, + preflightContinue: true +}) + +function createApp (argv = {}) { + // Override default configs (defaults) with passed-in params (argv) + argv = Object.assign({}, defaults, argv) + + argv.host = SolidHost.from(argv) + + argv.resourceMapper = new ResourceMapper({ + rootUrl: argv.serverUri, + rootPath: path.resolve(argv.root || process.cwd()), + includeHost: argv.multiuser, + defaultContentType: argv.defaultContentType + }) + + const configPath = config.initConfigDir(argv) + argv.templates = config.initTemplateDirs(configPath) + + config.printDebugInfo(argv) + + const ldp = new LDP(argv) + + const app = express() + + // Add PREP support + if (argv.prep) { + app.use(eventID) + app.use(acceptEvents, events, prep) + } + + initAppLocals(app, argv, ldp) + initHeaders(app) + initViews(app, configPath) + initLoggers() + + // Serve the public 'common' directory (for shared CSS files, etc) + app.use('/common', express.static(path.join(__dirname, '../common'))) + app.use('/', express.static(path.dirname(fileURLToPath(import.meta.resolve('mashlib/dist/databrowser.html'))), { index: false })) + routeResolvedFile(app, '/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js') + routeResolvedFile(app, '/common/js/', 'solid-auth-client/dist-lib/solid-auth-client.bundle.js.map') + app.use('/.well-known', express.static(path.join(__dirname, '../common/well-known'))) + + // Serve bootstrap from it's node_module directory + routeResolvedFile(app, '/common/css/', 'bootstrap/dist/css/bootstrap.min.css') + routeResolvedFile(app, '/common/css/', 'bootstrap/dist/css/bootstrap.min.css.map') + routeResolvedFile(app, '/common/fonts/', 'bootstrap/dist/fonts/glyphicons-halflings-regular.eot') + routeResolvedFile(app, '/common/fonts/', 'bootstrap/dist/fonts/glyphicons-halflings-regular.svg') + routeResolvedFile(app, '/common/fonts/', 'bootstrap/dist/fonts/glyphicons-halflings-regular.ttf') + routeResolvedFile(app, '/common/fonts/', 'bootstrap/dist/fonts/glyphicons-halflings-regular.woff') + routeResolvedFile(app, '/common/fonts/', 'bootstrap/dist/fonts/glyphicons-halflings-regular.woff2') + + // Serve OWASP password checker from it's node_module directory + routeResolvedFile(app, '/common/js/', 'owasp-password-strength-test/owasp-password-strength-test.js') + // Serve the TextEncoder polyfill + routeResolvedFile(app, '/common/js/', 'text-encoder-lite/text-encoder-lite.min.js') + + // Add CORS proxy + if (argv.proxy) { + console.warn('The proxy configuration option has been renamed to corsProxy.') + argv.corsProxy = argv.corsProxy || argv.proxy + delete argv.proxy + } + if (argv.corsProxy) { + corsProxy(app, argv.corsProxy) + } + + // Options handler + app.options('/*', options) + + // Set up API + if (argv.apiApps) { + app.use('/api/apps', express.static(argv.apiApps)) + } + + // Authenticate the user + if (argv.webid) { + initWebId(argv, app, ldp) + } + // Add Auth proxy (requires authentication) + if (argv.authProxy) { + authProxy(app, argv.authProxy) + } + + // Attach the LDP middleware + app.use('/', LdpMiddleware(corsSettings, argv.prep)) + + // https://stackoverflow.com/questions/51741383/nodejs-express-return-405-for-un-supported-method + app.use(function (req, res, next) { + const AllLayers = app._router.stack + const Layers = AllLayers.filter(x => x.name === 'bound dispatch' && x.regexp.test(req.path)) + + const Methods = [] + Layers.forEach(layer => { + for (const method in layer.route.methods) { + if (layer.route.methods[method] === true) { + Methods.push(method.toUpperCase()) + } + } + }) + + if (Layers.length !== 0 && !Methods.includes(req.method)) { + // res.setHeader('Allow', Methods.join(',')) + + if (req.method === 'OPTIONS') { + return res.send(Methods.join(', ')) + } else { + return res.status(405).send() + } + } else { + next() + } + }) + + // Errors + app.use(errorPages.handler) + + return app +} + +/** + * Initializes `app.locals` parameters for downstream use (typically by route + * handlers). + * + * @param app {Function} Express.js app instance + * @param argv {Object} Config options hashmap + * @param ldp {LDP} + */ +function initAppLocals (app, argv, ldp) { + app.locals.ldp = ldp + app.locals.appUrls = argv.apps // used for service capability discovery + app.locals.host = argv.host + app.locals.authMethod = argv.auth + app.locals.localAuth = argv.localAuth + app.locals.tokenService = new TokenService() + app.locals.enforceToc = argv.enforceToc + app.locals.tocUri = argv.tocUri + app.locals.disablePasswordChecks = argv.disablePasswordChecks + app.locals.prep = argv.prep + + if (argv.email && argv.email.host) { + app.locals.emailService = new EmailService(argv.templates.email, argv.email) + } +} + +/** + * Sets up headers common to all Solid requests (CORS-related, Allow, etc). + * + * @param app {Function} Express.js app instance + */ +function initHeaders (app) { + app.use(corsSettings) + + app.use((req, res, next) => { + res.set('X-Powered-By', 'solid-server/' + version) + + // Cors lib adds Vary: Origin automatically, but inreliably + res.set('Vary', 'Accept, Authorization, Origin') + + // Set default Allow methods + res.set('Allow', 'OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE') + next() + }) + + app.use('/', capabilityDiscovery()) + app.use('/', paymentPointerDiscovery()) +} + +/** + * Sets up the express rendering engine and views directory. + * + * @param app {Function} Express.js app + * @param configPath {string} + */ +function initViews (app, configPath) { + const viewsPath = config.initDefaultViews(configPath) + + app.set('views', viewsPath) + app.engine('.hbs', handlebars({ + extname: '.hbs', + partialsDir: viewsPath, + defaultLayout: null + })) + app.set('view engine', '.hbs') +} + +/** + * Sets up WebID-related functionality (account creation and authentication) + * + * @param argv {Object} + * @param app {Function} + * @param ldp {LDP} + */ +function initWebId (argv, app, ldp) { + config.ensureWelcomePage(argv) + + // Store the user's session key in a cookie + // (for same-domain browsing by people only) + const useSecureCookies = !!argv.sslKey // use secure cookies when over HTTPS + const sessionHandler = session(sessionSettings(useSecureCookies, argv.host)) + app.use(sessionHandler) + // Reject cookies from third-party applications. + // Otherwise, when a user is logged in to their Solid server, + // any third-party application could perform authenticated requests + // without permission by including the credentials set by the Solid server. + app.use((req, res, next) => { + const origin = req.get('origin') + const trustedOrigins = ldp.getTrustedOrigins(req) + const userId = req.session.userId + // Exception: allow logout requests from all third-party apps + // such that OIDC client can log out via cookie auth + // TODO: remove this exception when OIDC clients + // use Bearer token to authenticate instead of cookie + // (https://github.com/solid/node-solid-server/pull/835#issuecomment-426429003) + // + // Authentication cookies are an optimization: + // instead of going through the process of + // fully validating authentication on every request, + // we go through this process once, + // and store its successful result in a cookie + // that will be reused upon the next request. + // However, that cookie can then be sent by any server, + // even servers that have not gone through the proper authentication mechanism. + // However, if trusted origins are enabled, + // then any origin is allowed to take the shortcut route, + // since malicious origins will be banned at the ACL checking phase. + // https://github.com/solid/node-solid-server/issues/1117 + if (!argv.strictOrigin && !argv.host.allowsSessionFor(userId, origin, trustedOrigins) && !isLogoutRequest(req)) { + debug.authentication(`Rejecting session for ${userId} from ${origin}`) + // Destroy session data + delete req.session.userId + // Ensure this modified session is not saved + req.session.save = (done) => done() + } + if (isLogoutRequest(req)) { + delete req.session.userId + } + next() + }) + + const accountManager = AccountManager.from({ + authMethod: argv.auth, + emailService: app.locals.emailService, + tokenService: app.locals.tokenService, + host: argv.host, + accountTemplatePath: argv.templates.account, + store: ldp, + multiuser: argv.multiuser + }) + app.locals.accountManager = accountManager + + // Account Management API (create account, new cert) + app.use('/', API.accounts.middleware(accountManager)) + + // Set up authentication-related API endpoints and app.locals + initAuthentication(app, argv) + + if (argv.multiuser) { + app.use(vhost('*', LdpMiddleware(corsSettings, argv.prep))) + } +} + +function initLoggers () { + aclCheck.configureLogger(debug.ACL) +} + +/** + * Determines whether the given request is a logout request + */ +function isLogoutRequest (req) { + // TODO: this is a hack that hard-codes OIDC paths, + // this code should live in the OIDC module + return req.path === '/logout' || req.path === '/goodbye' +} + +/** + * Sets up authentication-related routes and handlers for the app. + * + * @param app {Object} Express.js app instance + * @param argv {Object} Config options hashmap + * @return {Promise} Resolves when authentication initialization is complete + */ +async function initAuthentication (app, argv) { + const auth = argv.forceUser ? 'forceUser' : argv.auth + if (!(auth in API.authn)) { + throw new Error(`Unsupported authentication scheme: ${auth}`) + } + await API.authn[auth].initialize(app, argv) +} + +/** + * Returns a settings object for Express.js sessions. + * + * @param secureCookies {boolean} + * @param host {SolidHost} + * + * @return {Object} `express-session` settings object + */ +function sessionSettings (secureCookies, host) { + const sessionSettings = { + name: 'nssidp.sid', + secret: uuid(), + saveUninitialized: false, + resave: false, + rolling: true, + cookie: { + maxAge: 24 * 60 * 60 * 1000 + } + } + // Cookies should set to be secure if https is on + if (secureCookies) { + sessionSettings.cookie.secure = true + } + + // Determine the cookie domain + sessionSettings.cookie.domain = host.cookieDomain + + return sessionSettings +} + +export default createApp diff --git a/lib/create-server.mjs b/lib/create-server.js similarity index 97% rename from lib/create-server.mjs rename to lib/create-server.js index 1e9b82229..d3d8fc60a 100644 --- a/lib/create-server.mjs +++ b/lib/create-server.js @@ -4,8 +4,8 @@ import https from 'https' import http from 'http' import SolidWs from 'solid-ws' import globalTunnel from 'global-tunnel-ng' -import debug from './debug.mjs' -import createApp from './create-app.mjs' +import debug from './debug.js' +import createApp from './create-app.js' function createServer (argv, app) { argv = argv || {} diff --git a/lib/debug.mjs b/lib/debug.js similarity index 96% rename from lib/debug.mjs rename to lib/debug.js index dde1f691b..3fd548938 100644 --- a/lib/debug.mjs +++ b/lib/debug.js @@ -1,37 +1,37 @@ -import debug from 'debug' - -export const handlers = debug('solid:handlers') -export const errors = debug('solid:errors') -export const ACL = debug('solid:ACL') -export const cache = debug('solid:cache') -export const parse = debug('solid:parse') -export const metadata = debug('solid:metadata') -export const authentication = debug('solid:authentication') -export const settings = debug('solid:settings') -export const server = debug('solid:server') -export const subscription = debug('solid:subscription') -export const container = debug('solid:container') -export const accounts = debug('solid:accounts') -export const email = debug('solid:email') -export const ldp = debug('solid:ldp') -export const fs = debug('solid:fs') -export const prep = debug('solid:prep') - -export default { - handlers, - errors, - ACL, - cache, - parse, - metadata, - authentication, - settings, - server, - subscription, - container, - accounts, - email, - ldp, - fs, - prep +import debug from 'debug' + +export const handlers = debug('solid:handlers') +export const errors = debug('solid:errors') +export const ACL = debug('solid:ACL') +export const cache = debug('solid:cache') +export const parse = debug('solid:parse') +export const metadata = debug('solid:metadata') +export const authentication = debug('solid:authentication') +export const settings = debug('solid:settings') +export const server = debug('solid:server') +export const subscription = debug('solid:subscription') +export const container = debug('solid:container') +export const accounts = debug('solid:accounts') +export const email = debug('solid:email') +export const ldp = debug('solid:ldp') +export const fs = debug('solid:fs') +export const prep = debug('solid:prep') + +export default { + handlers, + errors, + ACL, + cache, + parse, + metadata, + authentication, + settings, + server, + subscription, + container, + accounts, + email, + ldp, + fs, + prep } diff --git a/lib/handlers/allow.mjs b/lib/handlers/allow.js similarity index 94% rename from lib/handlers/allow.mjs rename to lib/handlers/allow.js index 25b8d9866..0dbe4a44f 100644 --- a/lib/handlers/allow.mjs +++ b/lib/handlers/allow.js @@ -1,79 +1,79 @@ -import ACL from '../acl-checker.mjs' -// import debug from '../debug.mjs' - -export default function allow (mode) { - return async function allowHandler (req, res, next) { - const ldp = req.app.locals.ldp || {} - if (!ldp.webid) { - return next() - } - - // Set up URL to filesystem mapping - const rootUrl = ldp.resourceMapper.resolveUrl(req.hostname) - - // Determine the actual path of the request - // (This is used as an ugly hack to check the ACL status of other resources.) - let resourcePath = res && res.locals && res.locals.path - ? res.locals.path - : req.path - - // Check whether the resource exists - let stat - try { - const ret = await ldp.exists(req.hostname, resourcePath) - stat = ret.stream - } catch (err) { - stat = null - } - - // Ensure directories always end in a slash - if (!resourcePath.endsWith('/') && stat && stat.isDirectory()) { - resourcePath += '/' - } - - const trustedOrigins = [ldp.resourceMapper.resolveUrl(req.hostname)].concat(ldp.trustedOrigins) - if (ldp.multiuser) { - trustedOrigins.push(ldp.serverUri) - } - // Obtain and store the ACL of the requested resource - const resourceUrl = rootUrl + resourcePath - // Ensure the user has the required permission - const userId = req.session.userId - try { - req.acl = ACL.createFromLDPAndRequest(resourceUrl, ldp, req) - - // if (resourceUrl.endsWith('.acl')) mode = 'Control' - const isAllowed = await req.acl.can(userId, mode, req.method, stat) - if (isAllowed) { - return next() - } - } catch (error) { next(error) } - if (mode === 'Read' && (resourcePath === '' || resourcePath === '/')) { - // This is a hack to make NSS check the ACL for representation that is served for root (if any) - // See https://github.com/solid/node-solid-server/issues/1063 for more info - const representationUrl = `${rootUrl}/index.html` - let representationPath - try { - representationPath = await ldp.resourceMapper.mapUrlToFile({ url: representationUrl }) - } catch (err) { - } - - // We ONLY want to do this when the HTML representation exists - if (representationPath) { - req.acl = ACL.createFromLDPAndRequest(representationUrl, ldp, req) - const representationIsAllowed = await req.acl.can(userId, mode) - if (representationIsAllowed) { - return next() - } - } - } - - // check if user is owner. Check isOwner from /.meta - try { - if (resourceUrl.endsWith('.acl') && (await ldp.isOwner(userId, req.hostname))) return next() - } catch (err) {} - const error = req.authError || await req.acl.getError(userId, mode) - // debug.handlers(`ALLOW -- ${mode} access denied to ${userId || '(none)'}: ${error.status} - ${error.message}`) - next(error) - } -} +import ACL from '../acl-checker.js' +// import debug from '../debug.js' + +export default function allow (mode) { + return async function allowHandler (req, res, next) { + const ldp = req.app.locals.ldp || {} + if (!ldp.webid) { + return next() + } + + // Set up URL to filesystem mapping + const rootUrl = ldp.resourceMapper.resolveUrl(req.hostname) + + // Determine the actual path of the request + // (This is used as an ugly hack to check the ACL status of other resources.) + let resourcePath = res && res.locals && res.locals.path + ? res.locals.path + : req.path + + // Check whether the resource exists + let stat + try { + const ret = await ldp.exists(req.hostname, resourcePath) + stat = ret.stream + } catch (err) { + stat = null + } + + // Ensure directories always end in a slash + if (!resourcePath.endsWith('/') && stat && stat.isDirectory()) { + resourcePath += '/' + } + + const trustedOrigins = [ldp.resourceMapper.resolveUrl(req.hostname)].concat(ldp.trustedOrigins) + if (ldp.multiuser) { + trustedOrigins.push(ldp.serverUri) + } + // Obtain and store the ACL of the requested resource + const resourceUrl = rootUrl + resourcePath + // Ensure the user has the required permission + const userId = req.session.userId + try { + req.acl = ACL.createFromLDPAndRequest(resourceUrl, ldp, req) + + // if (resourceUrl.endsWith('.acl')) mode = 'Control' + const isAllowed = await req.acl.can(userId, mode, req.method, stat) + if (isAllowed) { + return next() + } + } catch (error) { next(error) } + if (mode === 'Read' && (resourcePath === '' || resourcePath === '/')) { + // This is a hack to make NSS check the ACL for representation that is served for root (if any) + // See https://github.com/solid/node-solid-server/issues/1063 for more info + const representationUrl = `${rootUrl}/index.html` + let representationPath + try { + representationPath = await ldp.resourceMapper.mapUrlToFile({ url: representationUrl }) + } catch (err) { + } + + // We ONLY want to do this when the HTML representation exists + if (representationPath) { + req.acl = ACL.createFromLDPAndRequest(representationUrl, ldp, req) + const representationIsAllowed = await req.acl.can(userId, mode) + if (representationIsAllowed) { + return next() + } + } + } + + // check if user is owner. Check isOwner from /.meta + try { + if (resourceUrl.endsWith('.acl') && (await ldp.isOwner(userId, req.hostname))) return next() + } catch (err) {} + const error = req.authError || await req.acl.getError(userId, mode) + // debug.handlers(`ALLOW -- ${mode} access denied to ${userId || '(none)'}: ${error.status} - ${error.message}`) + next(error) + } +} diff --git a/lib/handlers/auth-proxy.mjs b/lib/handlers/auth-proxy.js similarity index 96% rename from lib/handlers/auth-proxy.mjs rename to lib/handlers/auth-proxy.js index 15472fd3b..dc3dfdb87 100644 --- a/lib/handlers/auth-proxy.mjs +++ b/lib/handlers/auth-proxy.js @@ -2,8 +2,8 @@ // that sends a logged-in Solid user's details to a backend import { createProxyMiddleware } from 'http-proxy-middleware' -import debug from '../debug.mjs' -import allow from './allow.mjs' +import debug from '../debug.js' +import allow from './allow.js' const PROXY_SETTINGS = { logLevel: 'silent', diff --git a/lib/handlers/copy.mjs b/lib/handlers/copy.js similarity index 89% rename from lib/handlers/copy.mjs rename to lib/handlers/copy.js index eff232dbc..5a69baaa8 100644 --- a/lib/handlers/copy.mjs +++ b/lib/handlers/copy.js @@ -1,35 +1,35 @@ -import debug from '../debug.mjs' -import HTTPError from '../http-error.mjs' -import ldpCopy from '../ldp-copy.mjs' -import { parse } from 'url' - -/** - * Handles HTTP COPY requests to import a given resource (specified in the - * `Source:` header) to a destination (specified in request path). - * For the moment, you can copy from public resources only (no auth delegation - * is implemented), and is mainly intended for use with - * "Save an external resource to Solid" type apps. - * @method handler - */ -export default async function handler (req, res, next) { - const copyFrom = req.header('Source') - if (!copyFrom) { - return next(HTTPError(400, 'Source header required')) - } - const fromExternal = !!parse(copyFrom).hostname - const ldp = req.app.locals.ldp - const serverRoot = ldp.resourceMapper.resolveUrl(req.hostname) - const copyFromUrl = fromExternal ? copyFrom : serverRoot + copyFrom - const copyToUrl = res.locals.path || req.path - try { - await ldpCopy(ldp.resourceMapper, copyToUrl, copyFromUrl) - } catch (err) { - const statusCode = err.statusCode || 500 - const errorMessage = err.statusMessage || err.message - debug.handlers('Error with COPY request:' + errorMessage) - return next(HTTPError(statusCode, errorMessage)) - } - res.set('Location', copyToUrl) - res.sendStatus(201) - next() -} +import debug from '../debug.js' +import HTTPError from '../http-error.js' +import ldpCopy from '../ldp-copy.js' +import { parse } from 'url' + +/** + * Handles HTTP COPY requests to import a given resource (specified in the + * `Source:` header) to a destination (specified in request path). + * For the moment, you can copy from public resources only (no auth delegation + * is implemented), and is mainly intended for use with + * "Save an external resource to Solid" type apps. + * @method handler + */ +export default async function handler (req, res, next) { + const copyFrom = req.header('Source') + if (!copyFrom) { + return next(HTTPError(400, 'Source header required')) + } + const fromExternal = !!parse(copyFrom).hostname + const ldp = req.app.locals.ldp + const serverRoot = ldp.resourceMapper.resolveUrl(req.hostname) + const copyFromUrl = fromExternal ? copyFrom : serverRoot + copyFrom + const copyToUrl = res.locals.path || req.path + try { + await ldpCopy(ldp.resourceMapper, copyToUrl, copyFromUrl) + } catch (err) { + const statusCode = err.statusCode || 500 + const errorMessage = err.statusMessage || err.message + debug.handlers('Error with COPY request:' + errorMessage) + return next(HTTPError(statusCode, errorMessage)) + } + res.set('Location', copyToUrl) + res.sendStatus(201) + next() +} diff --git a/lib/handlers/cors-proxy.mjs b/lib/handlers/cors-proxy.js similarity index 98% rename from lib/handlers/cors-proxy.mjs rename to lib/handlers/cors-proxy.js index 40ef1c12c..7ea25ddb6 100644 --- a/lib/handlers/cors-proxy.mjs +++ b/lib/handlers/cors-proxy.js @@ -1,6 +1,6 @@ import { createProxyMiddleware } from 'http-proxy-middleware' import cors from 'cors' -import debug from '../debug.mjs' +import debug from '../debug.js' import url from 'url' import dns from 'dns' import { isIP } from 'is-ip' diff --git a/lib/handlers/delete.mjs b/lib/handlers/delete.js similarity index 86% rename from lib/handlers/delete.mjs rename to lib/handlers/delete.js index 20d6d3ab7..ba5e8808f 100644 --- a/lib/handlers/delete.mjs +++ b/lib/handlers/delete.js @@ -1,21 +1,21 @@ -import { handlers as debug } from '../debug.mjs' - -export default async function handler (req, res, next) { - debug('DELETE -- Request on' + req.originalUrl) - - const ldp = req.app.locals.ldp - try { - await ldp.delete(req) - debug('DELETE -- Ok.') - res.sendStatus(200) - next() - } catch (err) { - debug('DELETE -- Failed to delete: ' + err) - - // method DELETE not allowed - if (err.status === 405) { - res.set('allow', 'OPTIONS, HEAD, GET, PATCH, POST, PUT') - } - next(err) - } +import { handlers as debug } from '../debug.js' + +export default async function handler (req, res, next) { + debug('DELETE -- Request on' + req.originalUrl) + + const ldp = req.app.locals.ldp + try { + await ldp.delete(req) + debug('DELETE -- Ok.') + res.sendStatus(200) + next() + } catch (err) { + debug('DELETE -- Failed to delete: ' + err) + + // method DELETE not allowed + if (err.status === 405) { + res.set('allow', 'OPTIONS, HEAD, GET, PATCH, POST, PUT') + } + next(err) + } } diff --git a/lib/handlers/error-pages.mjs b/lib/handlers/error-pages.js similarity index 93% rename from lib/handlers/error-pages.mjs rename to lib/handlers/error-pages.js index 92d268fdb..318e097b1 100644 --- a/lib/handlers/error-pages.mjs +++ b/lib/handlers/error-pages.js @@ -1,144 +1,144 @@ -import { server as debug } from '../debug.mjs' -import fs from 'fs' -import { createRequire } from 'module' -import * as util from '../utils.mjs' -import Auth from '../api/authn/index.mjs' - -const require = createRequire(import.meta.url) - -function statusCodeFor (err, req, authMethod) { - let statusCode = err.status || err.statusCode || 500 - - if (authMethod === 'oidc') { - statusCode = Auth.oidc.statusCodeOverride(statusCode, req) - } - - return statusCode -} - -export function setAuthenticateHeader (req, res, err) { - const locals = req.app.locals - const authMethod = locals.authMethod - - switch (authMethod) { - case 'oidc': - Auth.oidc.setAuthenticateHeader(req, res, err) - break - case 'tls': - Auth.tls.setAuthenticateHeader(req, res) - break - default: - break - } -} - -export function sendErrorResponse (statusCode, res, err) { - res.status(statusCode) - res.header('Content-Type', 'text/plain;charset=utf-8') - res.send(err.message + '\n') -} - -export function sendErrorPage (statusCode, res, err, ldp) { - const errorPage = ldp.errorPages + statusCode.toString() + '.html' - - return new Promise((resolve) => { - fs.readFile(errorPage, 'utf8', (readErr, text) => { - if (readErr) { - return resolve(sendErrorResponse(statusCode, res, err)) - } - - res.status(statusCode) - res.header('Content-Type', 'text/html') - res.send(text) - resolve() - }) - }) -} - -function renderDataBrowser (req, res) { - res.set('Content-Type', 'text/html') - const ldp = req.app.locals.ldp - const defaultDataBrowser = require.resolve('mashlib/dist/databrowser.html') - const dataBrowserPath = ldp.dataBrowserPath === 'default' ? defaultDataBrowser : ldp.dataBrowserPath - debug(' sending data browser file: ' + dataBrowserPath) - const dataBrowserHtml = fs.readFileSync(dataBrowserPath, 'utf8') - res.set('content-type', 'text/html') - res.send(dataBrowserHtml) -} - -export function handler (err, req, res, next) { - debug('Error page because of:', err) - - const locals = req.app.locals - const authMethod = locals.authMethod - const ldp = locals.ldp - - if (ldp.errorHandler) { - debug('Using custom error handler') - return ldp.errorHandler(err, req, res, next) - } - - const statusCode = statusCodeFor(err, req, authMethod) - switch (statusCode) { - case 401: - setAuthenticateHeader(req, res, err) - renderLoginRequired(req, res, err) - break - case 403: - renderNoPermission(req, res, err) - break - default: - if (ldp.noErrorPages) { - sendErrorResponse(statusCode, res, err) - } else { - sendErrorPage(statusCode, res, err, ldp) - } - } -} - -function renderLoginRequired (req, res, err) { - const currentUrl = util.fullUrlForReq(req) - debug(`Display login-required for ${currentUrl}`) - res.statusMessage = err.message - res.status(401) - if (req.accepts('html')) { - renderDataBrowser(req, res) - } else { - res.send('Not Authenticated') - } -} - -function renderNoPermission (req, res, err) { - const currentUrl = util.fullUrlForReq(req) - debug(`Display no-permission for ${currentUrl}`) - res.statusMessage = err.message - res.status(403) - if (req.accepts('html')) { - renderDataBrowser(req, res) - } else { - res.send('Not Authorized') - } -} - -export function redirectBody (url) { - return ` - - - -Redirecting... -If you are not redirected automatically, -follow the link to login -` -} - -export default { - handler, - redirectBody, - sendErrorPage, - sendErrorResponse, - setAuthenticateHeader -} +import { server as debug } from '../debug.js' +import fs from 'fs' +import { createRequire } from 'module' +import * as util from '../utils.js' +import Auth from '../api/authn/index.js' + +const require = createRequire(import.meta.url) + +function statusCodeFor (err, req, authMethod) { + let statusCode = err.status || err.statusCode || 500 + + if (authMethod === 'oidc') { + statusCode = Auth.oidc.statusCodeOverride(statusCode, req) + } + + return statusCode +} + +export function setAuthenticateHeader (req, res, err) { + const locals = req.app.locals + const authMethod = locals.authMethod + + switch (authMethod) { + case 'oidc': + Auth.oidc.setAuthenticateHeader(req, res, err) + break + case 'tls': + Auth.tls.setAuthenticateHeader(req, res) + break + default: + break + } +} + +export function sendErrorResponse (statusCode, res, err) { + res.status(statusCode) + res.header('Content-Type', 'text/plain;charset=utf-8') + res.send(err.message + '\n') +} + +export function sendErrorPage (statusCode, res, err, ldp) { + const errorPage = ldp.errorPages + statusCode.toString() + '.html' + + return new Promise((resolve) => { + fs.readFile(errorPage, 'utf8', (readErr, text) => { + if (readErr) { + return resolve(sendErrorResponse(statusCode, res, err)) + } + + res.status(statusCode) + res.header('Content-Type', 'text/html') + res.send(text) + resolve() + }) + }) +} + +function renderDataBrowser (req, res) { + res.set('Content-Type', 'text/html') + const ldp = req.app.locals.ldp + const defaultDataBrowser = require.resolve('mashlib/dist/databrowser.html') + const dataBrowserPath = ldp.dataBrowserPath === 'default' ? defaultDataBrowser : ldp.dataBrowserPath + debug(' sending data browser file: ' + dataBrowserPath) + const dataBrowserHtml = fs.readFileSync(dataBrowserPath, 'utf8') + res.set('content-type', 'text/html') + res.send(dataBrowserHtml) +} + +export function handler (err, req, res, next) { + debug('Error page because of:', err) + + const locals = req.app.locals + const authMethod = locals.authMethod + const ldp = locals.ldp + + if (ldp.errorHandler) { + debug('Using custom error handler') + return ldp.errorHandler(err, req, res, next) + } + + const statusCode = statusCodeFor(err, req, authMethod) + switch (statusCode) { + case 401: + setAuthenticateHeader(req, res, err) + renderLoginRequired(req, res, err) + break + case 403: + renderNoPermission(req, res, err) + break + default: + if (ldp.noErrorPages) { + sendErrorResponse(statusCode, res, err) + } else { + sendErrorPage(statusCode, res, err, ldp) + } + } +} + +function renderLoginRequired (req, res, err) { + const currentUrl = util.fullUrlForReq(req) + debug(`Display login-required for ${currentUrl}`) + res.statusMessage = err.message + res.status(401) + if (req.accepts('html')) { + renderDataBrowser(req, res) + } else { + res.send('Not Authenticated') + } +} + +function renderNoPermission (req, res, err) { + const currentUrl = util.fullUrlForReq(req) + debug(`Display no-permission for ${currentUrl}`) + res.statusMessage = err.message + res.status(403) + if (req.accepts('html')) { + renderDataBrowser(req, res) + } else { + res.send('Not Authorized') + } +} + +export function redirectBody (url) { + return ` + + + +Redirecting... +If you are not redirected automatically, +follow the link to login +` +} + +export default { + handler, + redirectBody, + sendErrorPage, + sendErrorResponse, + setAuthenticateHeader +} diff --git a/lib/handlers/get.mjs b/lib/handlers/get.js similarity index 95% rename from lib/handlers/get.mjs rename to lib/handlers/get.js index a76b42527..406c3f260 100644 --- a/lib/handlers/get.mjs +++ b/lib/handlers/get.js @@ -1,258 +1,258 @@ -/* eslint-disable no-mixed-operators, no-async-promise-executor */ - -import { createRequire } from 'module' -import fs from 'fs' -import { glob, hasMagic } from 'glob' -import _path from 'path' -import $rdf from 'rdflib' -import Negotiator from 'negotiator' -import mime from 'mime-types' -import debugModule from 'debug' -import allow from './allow.mjs' - -import { translate } from '../utils.mjs' -import HTTPError from '../http-error.mjs' - -import ldpModule from '../ldp.mjs' -const require = createRequire(import.meta.url) -const debug = debugModule('solid:get') -const debugGlob = debugModule('solid:glob') -const RDFs = ldpModule.mimeTypesAsArray() -const isRdf = ldpModule.mimeTypeIsRdf - -const prepConfig = 'accept=("message/rfc822" "application/ld+json" "text/turtle")' - -export default async function handler (req, res, next) { - const ldp = req.app.locals.ldp - const prep = req.app.locals.prep - const includeBody = req.method === 'GET' - const negotiator = new Negotiator(req) - const baseUri = ldp.resourceMapper.resolveUrl(req.hostname, req.path) - const path = res.locals.path || req.path - const requestedType = negotiator.mediaType() - const possibleRDFType = negotiator.mediaType(RDFs) - - // deprecated kept for compatibility - res.header('MS-Author-Via', 'SPARQL') - - res.header('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') - res.header('Accept-Post', '*/*') - if (!path.endsWith('/') && !hasMagic(path)) res.header('Accept-Put', '*/*') - - // Set live updates - if (ldp.live) { - res.header('Updates-Via', ldp.resourceMapper.resolveUrl(req.hostname).replace(/^http/, 'ws')) - } - - debug(req.originalUrl + ' on ' + req.hostname) - - const options = { - hostname: req.hostname, - path: path, - includeBody: includeBody, - possibleRDFType: possibleRDFType, - range: req.headers.range, - contentType: req.headers.accept - } - - let ret - try { - ret = await ldp.get(options, req.accepts(['html', 'turtle', 'rdf+xml', 'n3', 'ld+json']) === 'html') - } catch (err) { - // set Accept-Put if container do not exist - if (err.status === 404 && path.endsWith('/')) res.header('Accept-Put', 'text/turtle') - // use globHandler if magic is detected - if (err.status === 404 && hasMagic(path)) { - debug('forwarding to glob request') - return globHandler(req, res, next) - } else { - debug(req.method + ' -- Error: ' + err.status + ' ' + err.message) - return next(err) - } - } - - let stream - let contentType - let container - let contentRange - let chunksize - - if (ret) { - stream = ret.stream - contentType = ret.contentType - container = ret.container - contentRange = ret.contentRange - chunksize = ret.chunksize - } - - // Till here it must exist - if (!includeBody) { - debug('HEAD only') - res.setHeader('Content-Type', ret.contentType) - return res.status(200).send('OK') - } - - // Handle dataBrowser - if (requestedType && requestedType.includes('text/html')) { - const { path: filename } = await ldp.resourceMapper.mapUrlToFile({ url: options }) - const mimeTypeByExt = mime.lookup(_path.basename(filename)) - const isHtmlResource = mimeTypeByExt && mimeTypeByExt.includes('html') - const useDataBrowser = ldp.dataBrowserPath && ( - container || - [...RDFs, 'text/markdown'].includes(contentType) && !isHtmlResource && !ldp.suppressDataBrowser) - - if (useDataBrowser) { - res.setHeader('Content-Type', 'text/html') - - const defaultDataBrowser = require.resolve('mashlib/dist/databrowser.html') - const dataBrowserPath = ldp.dataBrowserPath === 'default' ? defaultDataBrowser : ldp.dataBrowserPath - debug(' sending data browser file: ' + dataBrowserPath) - res.sendFile(dataBrowserPath) - return - } else if (stream) { // EXIT text/html - res.setHeader('Content-Type', contentType) - return stream.pipe(res) - } - } - - // If request accepts the content-type we found - if (stream && negotiator.mediaType([contentType])) { - let headers = { - 'Content-Type': contentType - } - - if (contentRange) { - headers = { - ...headers, - 'Content-Range': contentRange, - 'Accept-Ranges': 'bytes', - 'Content-Length': chunksize - } - res.status(206) - } - - if (prep && isRdf(contentType) && !res.sendEvents({ - config: { prep: prepConfig }, - body: stream, - isBodyStream: true, - headers - })) return - - res.set(headers) - return stream.pipe(res) - } - - // If it is not in our RDFs we can't even translate, - // Sorry, we can't help - if (!possibleRDFType || !RDFs.includes(contentType)) { // possibleRDFType defaults to text/turtle - return next(HTTPError(406, 'Cannot serve requested type: ' + contentType)) - } - try { - // Translate from the contentType found to the possibleRDFType desired - const data = await translate(stream, baseUri, contentType, possibleRDFType) - debug(req.originalUrl + ' translating ' + contentType + ' -> ' + possibleRDFType) - const headers = { - 'Content-Type': possibleRDFType - } - if (prep && isRdf(contentType) && !res.sendEvents({ - config: { prep: prepConfig }, - body: data, - headers - })) return - res.setHeader('Content-Type', possibleRDFType) - res.send(data) - return next() - } catch (err) { - debug('error translating: ' + req.originalUrl + ' ' + contentType + ' -> ' + possibleRDFType + ' -- ' + 406 + ' ' + err.message) - return next(HTTPError(500, 'Cannot serve requested type: ' + requestedType)) - } -} - -async function globHandler (req, res, next) { - const { ldp } = req.app.locals - - // Ensure this is a glob for all files in a single folder - // https://github.com/solid/solid-spec/pull/148 - const requestUrl = await ldp.resourceMapper.getRequestUrl(req) - if (!/^[^*]+\/\*$/.test(requestUrl)) { - return next(HTTPError(404, 'Unsupported glob pattern')) - } - - // Extract the folder on the file system from the URL glob - const folderUrl = requestUrl.substr(0, requestUrl.length - 1) - const folderPath = (await ldp.resourceMapper.mapUrlToFile({ url: folderUrl, searchIndex: false })).path - - const globOptions = { - noext: true, - nobrace: true, - nodir: true - } - - try { - const matches = await glob(`${folderPath}*`, globOptions) - if (matches.length === 0) { - debugGlob('No files matching the pattern') - return next(HTTPError(404, 'No files matching glob pattern')) - } - - // Matches found - const globGraph = $rdf.graph() - - debugGlob('found matches ' + matches) - await Promise.all(matches.map(match => new Promise(async (resolve, reject) => { - const urlData = await ldp.resourceMapper.mapFileToUrl({ path: match, hostname: req.hostname }) - fs.readFile(match, { encoding: 'utf8' }, function (err, fileData) { - if (err) { - debugGlob('error ' + err) - return resolve() - } - // Files should be Turtle - if (urlData.contentType !== 'text/turtle') { - return resolve() - } - // The agent should have Read access to the file - hasReadPermissions(match, req, res, function (allowed) { - if (allowed) { - try { - $rdf.parse(fileData, globGraph, urlData.url, 'text/turtle') - } catch (parseErr) { - debugGlob(`error parsing ${match}: ${parseErr}`) - } - } - return resolve() - }) - }) - }))) - - const data = $rdf.serialize(undefined, globGraph, requestUrl, 'text/turtle') - // TODO this should be added as a middleware in the routes - res.setHeader('Content-Type', 'text/turtle') - debugGlob('returning turtle') - - res.send(data) - next() - } catch (err) { - debugGlob('Error during glob: ' + err) - return next(HTTPError(500, 'Error processing glob pattern')) - } -} - -// TODO: get rid of this ugly hack that uses the Allow handler to check read permissions -function hasReadPermissions (file, req, res, callback) { - const ldp = req.app.locals.ldp - - if (!ldp.webid) { - // FIXME: what is the rule that causes - // "Unexpected literal in error position of callback" in `npm run standard`? - - return callback(true) - } - - const root = ldp.resourceMapper.resolveFilePath(req.hostname) - const relativePath = '/' + _path.relative(root, file) - res.locals.path = relativePath - // FIXME: what is the rule that causes - // "Unexpected literal in error position of callback" in `npm run standard`? - - allow('Read')(req, res, err => callback(!err)) -} +/* eslint-disable no-mixed-operators, no-async-promise-executor */ + +import { createRequire } from 'module' +import fs from 'fs' +import { glob, hasMagic } from 'glob' +import _path from 'path' +import $rdf from 'rdflib' +import Negotiator from 'negotiator' +import mime from 'mime-types' +import debugModule from 'debug' +import allow from './allow.js' + +import { translate } from '../utils.js' +import HTTPError from '../http-error.js' + +import ldpModule from '../ldp.js' +const require = createRequire(import.meta.url) +const debug = debugModule('solid:get') +const debugGlob = debugModule('solid:glob') +const RDFs = ldpModule.mimeTypesAsArray() +const isRdf = ldpModule.mimeTypeIsRdf + +const prepConfig = 'accept=("message/rfc822" "application/ld+json" "text/turtle")' + +export default async function handler (req, res, next) { + const ldp = req.app.locals.ldp + const prep = req.app.locals.prep + const includeBody = req.method === 'GET' + const negotiator = new Negotiator(req) + const baseUri = ldp.resourceMapper.resolveUrl(req.hostname, req.path) + const path = res.locals.path || req.path + const requestedType = negotiator.mediaType() + const possibleRDFType = negotiator.mediaType(RDFs) + + // deprecated kept for compatibility + res.header('MS-Author-Via', 'SPARQL') + + res.header('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') + res.header('Accept-Post', '*/*') + if (!path.endsWith('/') && !hasMagic(path)) res.header('Accept-Put', '*/*') + + // Set live updates + if (ldp.live) { + res.header('Updates-Via', ldp.resourceMapper.resolveUrl(req.hostname).replace(/^http/, 'ws')) + } + + debug(req.originalUrl + ' on ' + req.hostname) + + const options = { + hostname: req.hostname, + path: path, + includeBody: includeBody, + possibleRDFType: possibleRDFType, + range: req.headers.range, + contentType: req.headers.accept + } + + let ret + try { + ret = await ldp.get(options, req.accepts(['html', 'turtle', 'rdf+xml', 'n3', 'ld+json']) === 'html') + } catch (err) { + // set Accept-Put if container do not exist + if (err.status === 404 && path.endsWith('/')) res.header('Accept-Put', 'text/turtle') + // use globHandler if magic is detected + if (err.status === 404 && hasMagic(path)) { + debug('forwarding to glob request') + return globHandler(req, res, next) + } else { + debug(req.method + ' -- Error: ' + err.status + ' ' + err.message) + return next(err) + } + } + + let stream + let contentType + let container + let contentRange + let chunksize + + if (ret) { + stream = ret.stream + contentType = ret.contentType + container = ret.container + contentRange = ret.contentRange + chunksize = ret.chunksize + } + + // Till here it must exist + if (!includeBody) { + debug('HEAD only') + res.setHeader('Content-Type', ret.contentType) + return res.status(200).send('OK') + } + + // Handle dataBrowser + if (requestedType && requestedType.includes('text/html')) { + const { path: filename } = await ldp.resourceMapper.mapUrlToFile({ url: options }) + const mimeTypeByExt = mime.lookup(_path.basename(filename)) + const isHtmlResource = mimeTypeByExt && mimeTypeByExt.includes('html') + const useDataBrowser = ldp.dataBrowserPath && ( + container || + [...RDFs, 'text/markdown'].includes(contentType) && !isHtmlResource && !ldp.suppressDataBrowser) + + if (useDataBrowser) { + res.setHeader('Content-Type', 'text/html') + + const defaultDataBrowser = require.resolve('mashlib/dist/databrowser.html') + const dataBrowserPath = ldp.dataBrowserPath === 'default' ? defaultDataBrowser : ldp.dataBrowserPath + debug(' sending data browser file: ' + dataBrowserPath) + res.sendFile(dataBrowserPath) + return + } else if (stream) { // EXIT text/html + res.setHeader('Content-Type', contentType) + return stream.pipe(res) + } + } + + // If request accepts the content-type we found + if (stream && negotiator.mediaType([contentType])) { + let headers = { + 'Content-Type': contentType + } + + if (contentRange) { + headers = { + ...headers, + 'Content-Range': contentRange, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunksize + } + res.status(206) + } + + if (prep && isRdf(contentType) && !res.sendEvents({ + config: { prep: prepConfig }, + body: stream, + isBodyStream: true, + headers + })) return + + res.set(headers) + return stream.pipe(res) + } + + // If it is not in our RDFs we can't even translate, + // Sorry, we can't help + if (!possibleRDFType || !RDFs.includes(contentType)) { // possibleRDFType defaults to text/turtle + return next(HTTPError(406, 'Cannot serve requested type: ' + contentType)) + } + try { + // Translate from the contentType found to the possibleRDFType desired + const data = await translate(stream, baseUri, contentType, possibleRDFType) + debug(req.originalUrl + ' translating ' + contentType + ' -> ' + possibleRDFType) + const headers = { + 'Content-Type': possibleRDFType + } + if (prep && isRdf(contentType) && !res.sendEvents({ + config: { prep: prepConfig }, + body: data, + headers + })) return + res.setHeader('Content-Type', possibleRDFType) + res.send(data) + return next() + } catch (err) { + debug('error translating: ' + req.originalUrl + ' ' + contentType + ' -> ' + possibleRDFType + ' -- ' + 406 + ' ' + err.message) + return next(HTTPError(500, 'Cannot serve requested type: ' + requestedType)) + } +} + +async function globHandler (req, res, next) { + const { ldp } = req.app.locals + + // Ensure this is a glob for all files in a single folder + // https://github.com/solid/solid-spec/pull/148 + const requestUrl = await ldp.resourceMapper.getRequestUrl(req) + if (!/^[^*]+\/\*$/.test(requestUrl)) { + return next(HTTPError(404, 'Unsupported glob pattern')) + } + + // Extract the folder on the file system from the URL glob + const folderUrl = requestUrl.substr(0, requestUrl.length - 1) + const folderPath = (await ldp.resourceMapper.mapUrlToFile({ url: folderUrl, searchIndex: false })).path + + const globOptions = { + noext: true, + nobrace: true, + nodir: true + } + + try { + const matches = await glob(`${folderPath}*`, globOptions) + if (matches.length === 0) { + debugGlob('No files matching the pattern') + return next(HTTPError(404, 'No files matching glob pattern')) + } + + // Matches found + const globGraph = $rdf.graph() + + debugGlob('found matches ' + matches) + await Promise.all(matches.map(match => new Promise(async (resolve, reject) => { + const urlData = await ldp.resourceMapper.mapFileToUrl({ path: match, hostname: req.hostname }) + fs.readFile(match, { encoding: 'utf8' }, function (err, fileData) { + if (err) { + debugGlob('error ' + err) + return resolve() + } + // Files should be Turtle + if (urlData.contentType !== 'text/turtle') { + return resolve() + } + // The agent should have Read access to the file + hasReadPermissions(match, req, res, function (allowed) { + if (allowed) { + try { + $rdf.parse(fileData, globGraph, urlData.url, 'text/turtle') + } catch (parseErr) { + debugGlob(`error parsing ${match}: ${parseErr}`) + } + } + return resolve() + }) + }) + }))) + + const data = $rdf.serialize(undefined, globGraph, requestUrl, 'text/turtle') + // TODO this should be added as a middleware in the routes + res.setHeader('Content-Type', 'text/turtle') + debugGlob('returning turtle') + + res.send(data) + next() + } catch (err) { + debugGlob('Error during glob: ' + err) + return next(HTTPError(500, 'Error processing glob pattern')) + } +} + +// TODO: get rid of this ugly hack that uses the Allow handler to check read permissions +function hasReadPermissions (file, req, res, callback) { + const ldp = req.app.locals.ldp + + if (!ldp.webid) { + // FIXME: what is the rule that causes + // "Unexpected literal in error position of callback" in `npm run standard`? + + return callback(true) + } + + const root = ldp.resourceMapper.resolveFilePath(req.hostname) + const relativePath = '/' + _path.relative(root, file) + res.locals.path = relativePath + // FIXME: what is the rule that causes + // "Unexpected literal in error position of callback" in `npm run standard`? + + allow('Read')(req, res, err => callback(!err)) +} diff --git a/lib/handlers/index.mjs b/lib/handlers/index.js similarity index 100% rename from lib/handlers/index.mjs rename to lib/handlers/index.js diff --git a/lib/handlers/notify.mjs b/lib/handlers/notify.js similarity index 97% rename from lib/handlers/notify.mjs rename to lib/handlers/notify.js index 88c880927..4af9b6d46 100644 --- a/lib/handlers/notify.mjs +++ b/lib/handlers/notify.js @@ -1,7 +1,7 @@ import { posix as libPath } from 'path' import { header as headerTemplate } from 'express-prep/templates' -import solidRDFTemplate from '../rdf-notification-template.mjs' -import debug from '../debug.mjs' +import solidRDFTemplate from '../rdf-notification-template.js' +import debug from '../debug.js' const debugPrep = debug.prep const ALLOWED_RDF_MIME_TYPES = [ diff --git a/lib/handlers/options.mjs b/lib/handlers/options.js similarity index 95% rename from lib/handlers/options.mjs rename to lib/handlers/options.js index ae1c0b2bb..083654fe8 100644 --- a/lib/handlers/options.mjs +++ b/lib/handlers/options.js @@ -1,4 +1,4 @@ -import { addLink } from '../header.mjs' +import { addLink } from '../header.js' import url from 'url' export default function handler (req, res, next) { diff --git a/lib/handlers/patch.mjs b/lib/handlers/patch.js similarity index 94% rename from lib/handlers/patch.mjs rename to lib/handlers/patch.js index 61be51035..adbefb4b6 100644 --- a/lib/handlers/patch.mjs +++ b/lib/handlers/patch.js @@ -1,241 +1,241 @@ -// Express handler for LDP PATCH requests -import bodyParser from 'body-parser' -import fs from 'fs' -import debugModule from '../debug.mjs' -import error from '../http-error.mjs' -import $rdf from 'rdflib' -import crypto from 'crypto' -import { overQuota, getContentType } from '../utils.mjs' -import withLock from '../lock.mjs' -import sparqlUpdateParser from './patch/sparql-update-parser.mjs' -import n3PatchParser from './patch/n3-patch-parser.mjs' - -const debug = debugModule.handlers - -// Patch parsers by request body content type -const PATCH_PARSERS = { - 'application/sparql-update': sparqlUpdateParser, - 'application/sparql-update-single-match': sparqlUpdateParser, - 'text/n3': n3PatchParser -} - -// use media-type as contentType for new RDF resource -const DEFAULT_FOR_NEW_CONTENT_TYPE = 'text/turtle' - -function contentTypeForNew (req) { - let contentTypeForNew = DEFAULT_FOR_NEW_CONTENT_TYPE - if (req.path.endsWith('.jsonld')) contentTypeForNew = 'application/ld+json' - else if (req.path.endsWith('.n3')) contentTypeForNew = 'text/n3' - else if (req.path.endsWith('.rdf')) contentTypeForNew = 'application/rdf+xml' - return contentTypeForNew -} - -function contentForNew (contentType) { - let contentForNew = '' - if (contentType.includes('ld+json')) contentForNew = JSON.stringify('{}') - else if (contentType.includes('rdf+xml')) contentForNew = '\n\n' - return contentForNew -} - -// Handles a PATCH request -async function patchHandler (req, res, next) { - debug(`PATCH -- ${req.originalUrl}`) - try { - // Obtain details of the target resource - const ldp = req.app.locals.ldp - let path, contentType - let resourceExists = true - try { - // First check if the file already exists - ({ path, contentType } = await ldp.resourceMapper.mapUrlToFile({ url: req })) - } catch (err) { - // debug('PATCH -- File does not exist, creating new resource. Error:', err.message) - // If the file doesn't exist, request to create one with the file media type as contentType - ({ path, contentType } = await ldp.resourceMapper.mapUrlToFile( - { url: req, createIfNotExists: true, contentType: contentTypeForNew(req) })) - // check if a folder with same name exists - try { - await ldp.checkItemName(req) - } catch (err) { - return next(err) - } - resourceExists = false - } - const { url } = await ldp.resourceMapper.mapFileToUrl({ path, hostname: req.hostname }) - const resource = { path, contentType, url } - debug('PATCH -- Target <%s> (%s)', url, contentType) - - // Obtain details of the patch document - const patch = {} - patch.text = req.body ? req.body.toString() : '' - patch.uri = `${url}#patch-${hash(patch.text)}` - patch.contentType = getContentType(req.headers) - if (!patch.contentType) { - throw error(400, 'PATCH request requires a content-type via the Content-Type header') - } - debug('PATCH -- Received patch (%d bytes, %s)', patch.text.length, patch.contentType) - const parsePatch = PATCH_PARSERS[patch.contentType] - if (!parsePatch) { - throw error(415, `Unsupported patch content type: ${patch.contentType}`) - } - res.header('Accept-Patch', patch.contentType) // is this needed ? - // Parse the patch document and verify permissions - const patchObject = await parsePatch(url, patch.uri, patch.text) - await checkPermission(req, patchObject, resourceExists) - - // Create the enclosing directory, if necessary - await ldp.createDirectory(path, req.hostname) - - // Patch the graph and write it back to the file - const result = await withLock(path, async () => { - const graph = await readGraph(resource) - await applyPatch(patchObject, graph, url) - return writeGraph(graph, resource, ldp.resourceMapper.resolveFilePath(req.hostname), ldp.serverUri) - }) - // Send the status and result to the client - res.status(resourceExists ? 200 : 201) - res.send(result) - } catch (err) { - return next(err) - } - return next() -} - -// Reads the request body and calls the actual patch handler -function handler (req, res, next) { - debug('PATCH -- handler called for:', req.originalUrl || req.url) - readEntity(req, res, () => patchHandler(req, res, next)) -} - -const readEntity = bodyParser.text({ type: () => true }) - -// Reads the RDF graph in the given resource -function readGraph (resource) { - // Read the resource's file - return new Promise((resolve, reject) => - fs.readFile(resource.path, { encoding: 'utf8' }, function (err, fileContents) { - if (err) { - // If the file does not exist, assume empty contents - // (it will be created after a successful patch) - if (err.code === 'ENOENT') { - fileContents = contentForNew(resource.contentType) - // Fail on all other errors - } else { - return reject(error(500, `Original file read error: ${err}`)) - } - } - debug('PATCH -- Read target file (%d bytes)', fileContents.length) - fileContents = resource.contentType.includes('json') ? JSON.parse(fileContents) : fileContents - resolve(fileContents) - }) - ) - // Parse the resource's file contents - .then((fileContents) => { - const graph = $rdf.graph() - debug('PATCH -- Reading %s with content type %s', resource.url, resource.contentType) - try { - $rdf.parse(fileContents, graph, resource.url, resource.contentType) - } catch (err) { - throw error(500, `Patch: Target ${resource.contentType} file syntax error: ${err}`) - } - debug('PATCH -- Parsed target file') - return graph - }) -} - -// Verifies whether the user is allowed to perform the patch on the target -async function checkPermission (request, patchObject, resourceExists) { - // If no ACL object was passed down, assume permissions are okay. - if (!request.acl) return Promise.resolve(patchObject) - // At this point, we already assume append access, - // as this can be checked upfront before parsing the patch. - // Now that we know the details of the patch, - // we might need to perform additional checks. - let modes = [] - const { acl, session: { userId } } = request - // Read access is required for DELETE and WHERE. - // If we would allows users without read access, - // they could use DELETE or WHERE to trigger 200 or 409, - // and thereby guess the existence of certain triples. - // DELETE additionally requires write access. - if (patchObject.delete) { - // ACTUALLY Read not needed by solid/test-suite only Write - modes = ['Read', 'Write'] - // checks = [acl.can(userId, 'Read'), acl.can(userId, 'Write')] - } else if (patchObject.where) { - modes = modes.concat(['Read']) - // checks = [acl.can(userId, 'Read')] - } - const allowed = await Promise.all(modes.map(mode => acl.can(userId, mode, request.method, resourceExists))) - const allAllowed = allowed.reduce((memo, allowed) => memo && allowed, true) - if (!allAllowed) { - // check owner with Control - const ldp = request.app.locals.ldp - if (request.path.endsWith('.acl') && await ldp.isOwner(userId, request.hostname)) return Promise.resolve(patchObject) - - const errors = await Promise.all(modes.map(mode => acl.getError(userId, mode))) - const error = errors.filter(error => !!error) - .reduce((prevErr, err) => prevErr.status > err.status ? prevErr : err, { status: 0 }) - return Promise.reject(error) - } - return Promise.resolve(patchObject) -} - -// Applies the patch to the RDF graph -function applyPatch (patchObject, graph, url) { - debug('PATCH -- Applying patch') - return new Promise((resolve, reject) => - graph.applyPatch(patchObject, graph.sym(url), (err) => { - if (err) { - const message = err.message || err // returns string at the moment - debug(`PATCH -- FAILED. Returning 409. Message: '${message}'`) - return reject(error(409, `The patch could not be applied. ${message}`)) - } - resolve(graph) - }) - ) -} - -// Writes the RDF graph to the given resource -function writeGraph (graph, resource, root, serverUri) { - debug('PATCH -- Writing patched file') - return new Promise((resolve, reject) => { - const resourceSym = graph.sym(resource.url) - - function doWrite (serialized) { - // First check if we are above quota - overQuota(root, serverUri).then((isOverQuota) => { - if (isOverQuota) { - return reject(error(413, - 'User has exceeded their storage quota')) - } - - fs.writeFile(resource.path, serialized, { encoding: 'utf8' }, function (err) { - if (err) { - return reject(error(500, `Failed to write file after patch: ${err}`)) - } - debug('PATCH -- applied successfully') - resolve('Patch applied successfully.\n') - }) - }).catch(() => reject(error(500, 'Error finding user quota'))) - } - - if (resource.contentType === 'application/ld+json') { - $rdf.serialize(resourceSym, graph, resource.url, resource.contentType, function (err, result) { - if (err) return reject(error(500, `Failed to serialize after patch: ${err}`)) - doWrite(result) - }) - } else { - const serialized = $rdf.serialize(resourceSym, graph, resource.url, resource.contentType) - debug(`PATCH -- Serialized graph:\n${serialized}`) - doWrite(serialized) - } - }) -} - -// Creates a hash of the given text -function hash (text) { - return crypto.createHash('md5').update(text).digest('hex') -} - -export default handler +// Express handler for LDP PATCH requests +import bodyParser from 'body-parser' +import fs from 'fs' +import debugModule from '../debug.js' +import error from '../http-error.js' +import $rdf from 'rdflib' +import crypto from 'crypto' +import { overQuota, getContentType } from '../utils.js' +import withLock from '../lock.js' +import sparqlUpdateParser from './patch/sparql-update-parser.js' +import n3PatchParser from './patch/n3-patch-parser.js' + +const debug = debugModule.handlers + +// Patch parsers by request body content type +const PATCH_PARSERS = { + 'application/sparql-update': sparqlUpdateParser, + 'application/sparql-update-single-match': sparqlUpdateParser, + 'text/n3': n3PatchParser +} + +// use media-type as contentType for new RDF resource +const DEFAULT_FOR_NEW_CONTENT_TYPE = 'text/turtle' + +function contentTypeForNew (req) { + let contentTypeForNew = DEFAULT_FOR_NEW_CONTENT_TYPE + if (req.path.endsWith('.jsonld')) contentTypeForNew = 'application/ld+json' + else if (req.path.endsWith('.n3')) contentTypeForNew = 'text/n3' + else if (req.path.endsWith('.rdf')) contentTypeForNew = 'application/rdf+xml' + return contentTypeForNew +} + +function contentForNew (contentType) { + let contentForNew = '' + if (contentType.includes('ld+json')) contentForNew = JSON.stringify('{}') + else if (contentType.includes('rdf+xml')) contentForNew = '\n\n' + return contentForNew +} + +// Handles a PATCH request +async function patchHandler (req, res, next) { + debug(`PATCH -- ${req.originalUrl}`) + try { + // Obtain details of the target resource + const ldp = req.app.locals.ldp + let path, contentType + let resourceExists = true + try { + // First check if the file already exists + ({ path, contentType } = await ldp.resourceMapper.mapUrlToFile({ url: req })) + } catch (err) { + // debug('PATCH -- File does not exist, creating new resource. Error:', err.message) + // If the file doesn't exist, request to create one with the file media type as contentType + ({ path, contentType } = await ldp.resourceMapper.mapUrlToFile( + { url: req, createIfNotExists: true, contentType: contentTypeForNew(req) })) + // check if a folder with same name exists + try { + await ldp.checkItemName(req) + } catch (err) { + return next(err) + } + resourceExists = false + } + const { url } = await ldp.resourceMapper.mapFileToUrl({ path, hostname: req.hostname }) + const resource = { path, contentType, url } + debug('PATCH -- Target <%s> (%s)', url, contentType) + + // Obtain details of the patch document + const patch = {} + patch.text = req.body ? req.body.toString() : '' + patch.uri = `${url}#patch-${hash(patch.text)}` + patch.contentType = getContentType(req.headers) + if (!patch.contentType) { + throw error(400, 'PATCH request requires a content-type via the Content-Type header') + } + debug('PATCH -- Received patch (%d bytes, %s)', patch.text.length, patch.contentType) + const parsePatch = PATCH_PARSERS[patch.contentType] + if (!parsePatch) { + throw error(415, `Unsupported patch content type: ${patch.contentType}`) + } + res.header('Accept-Patch', patch.contentType) // is this needed ? + // Parse the patch document and verify permissions + const patchObject = await parsePatch(url, patch.uri, patch.text) + await checkPermission(req, patchObject, resourceExists) + + // Create the enclosing directory, if necessary + await ldp.createDirectory(path, req.hostname) + + // Patch the graph and write it back to the file + const result = await withLock(path, async () => { + const graph = await readGraph(resource) + await applyPatch(patchObject, graph, url) + return writeGraph(graph, resource, ldp.resourceMapper.resolveFilePath(req.hostname), ldp.serverUri) + }) + // Send the status and result to the client + res.status(resourceExists ? 200 : 201) + res.send(result) + } catch (err) { + return next(err) + } + return next() +} + +// Reads the request body and calls the actual patch handler +function handler (req, res, next) { + debug('PATCH -- handler called for:', req.originalUrl || req.url) + readEntity(req, res, () => patchHandler(req, res, next)) +} + +const readEntity = bodyParser.text({ type: () => true }) + +// Reads the RDF graph in the given resource +function readGraph (resource) { + // Read the resource's file + return new Promise((resolve, reject) => + fs.readFile(resource.path, { encoding: 'utf8' }, function (err, fileContents) { + if (err) { + // If the file does not exist, assume empty contents + // (it will be created after a successful patch) + if (err.code === 'ENOENT') { + fileContents = contentForNew(resource.contentType) + // Fail on all other errors + } else { + return reject(error(500, `Original file read error: ${err}`)) + } + } + debug('PATCH -- Read target file (%d bytes)', fileContents.length) + fileContents = resource.contentType.includes('json') ? JSON.parse(fileContents) : fileContents + resolve(fileContents) + }) + ) + // Parse the resource's file contents + .then((fileContents) => { + const graph = $rdf.graph() + debug('PATCH -- Reading %s with content type %s', resource.url, resource.contentType) + try { + $rdf.parse(fileContents, graph, resource.url, resource.contentType) + } catch (err) { + throw error(500, `Patch: Target ${resource.contentType} file syntax error: ${err}`) + } + debug('PATCH -- Parsed target file') + return graph + }) +} + +// Verifies whether the user is allowed to perform the patch on the target +async function checkPermission (request, patchObject, resourceExists) { + // If no ACL object was passed down, assume permissions are okay. + if (!request.acl) return Promise.resolve(patchObject) + // At this point, we already assume append access, + // as this can be checked upfront before parsing the patch. + // Now that we know the details of the patch, + // we might need to perform additional checks. + let modes = [] + const { acl, session: { userId } } = request + // Read access is required for DELETE and WHERE. + // If we would allows users without read access, + // they could use DELETE or WHERE to trigger 200 or 409, + // and thereby guess the existence of certain triples. + // DELETE additionally requires write access. + if (patchObject.delete) { + // ACTUALLY Read not needed by solid/test-suite only Write + modes = ['Read', 'Write'] + // checks = [acl.can(userId, 'Read'), acl.can(userId, 'Write')] + } else if (patchObject.where) { + modes = modes.concat(['Read']) + // checks = [acl.can(userId, 'Read')] + } + const allowed = await Promise.all(modes.map(mode => acl.can(userId, mode, request.method, resourceExists))) + const allAllowed = allowed.reduce((memo, allowed) => memo && allowed, true) + if (!allAllowed) { + // check owner with Control + const ldp = request.app.locals.ldp + if (request.path.endsWith('.acl') && await ldp.isOwner(userId, request.hostname)) return Promise.resolve(patchObject) + + const errors = await Promise.all(modes.map(mode => acl.getError(userId, mode))) + const error = errors.filter(error => !!error) + .reduce((prevErr, err) => prevErr.status > err.status ? prevErr : err, { status: 0 }) + return Promise.reject(error) + } + return Promise.resolve(patchObject) +} + +// Applies the patch to the RDF graph +function applyPatch (patchObject, graph, url) { + debug('PATCH -- Applying patch') + return new Promise((resolve, reject) => + graph.applyPatch(patchObject, graph.sym(url), (err) => { + if (err) { + const message = err.message || err // returns string at the moment + debug(`PATCH -- FAILED. Returning 409. Message: '${message}'`) + return reject(error(409, `The patch could not be applied. ${message}`)) + } + resolve(graph) + }) + ) +} + +// Writes the RDF graph to the given resource +function writeGraph (graph, resource, root, serverUri) { + debug('PATCH -- Writing patched file') + return new Promise((resolve, reject) => { + const resourceSym = graph.sym(resource.url) + + function doWrite (serialized) { + // First check if we are above quota + overQuota(root, serverUri).then((isOverQuota) => { + if (isOverQuota) { + return reject(error(413, + 'User has exceeded their storage quota')) + } + + fs.writeFile(resource.path, serialized, { encoding: 'utf8' }, function (err) { + if (err) { + return reject(error(500, `Failed to write file after patch: ${err}`)) + } + debug('PATCH -- applied successfully') + resolve('Patch applied successfully.\n') + }) + }).catch(() => reject(error(500, 'Error finding user quota'))) + } + + if (resource.contentType === 'application/ld+json') { + $rdf.serialize(resourceSym, graph, resource.url, resource.contentType, function (err, result) { + if (err) return reject(error(500, `Failed to serialize after patch: ${err}`)) + doWrite(result) + }) + } else { + const serialized = $rdf.serialize(resourceSym, graph, resource.url, resource.contentType) + debug(`PATCH -- Serialized graph:\n${serialized}`) + doWrite(serialized) + } + }) +} + +// Creates a hash of the given text +function hash (text) { + return crypto.createHash('md5').update(text).digest('hex') +} + +export default handler diff --git a/lib/handlers/patch/n3-patch-parser.mjs b/lib/handlers/patch/n3-patch-parser.js similarity index 95% rename from lib/handlers/patch/n3-patch-parser.mjs rename to lib/handlers/patch/n3-patch-parser.js index 2df6326a1..8eec5fa1c 100644 --- a/lib/handlers/patch/n3-patch-parser.mjs +++ b/lib/handlers/patch/n3-patch-parser.js @@ -1,57 +1,57 @@ -// Parses a text/n3 patch - -import $rdf from 'rdflib' -import error from '../../http-error.mjs' - -const PATCH_NS = 'http://www.w3.org/ns/solid/terms#' -const PREFIXES = `PREFIX solid: <${PATCH_NS}>\n` - -// Parses the given N3 patch document -export default async function parsePatchDocument (targetURI, patchURI, patchText) { - // Parse the N3 document into triples - const patchGraph = $rdf.graph() - try { - $rdf.parse(patchText, patchGraph, patchURI, 'text/n3') - } catch (err) { - throw error(400, `Patch document syntax error: ${err}`) - } - - // Query the N3 document for insertions and deletions - let firstResult - try { // solid/protocol v0.9.0 - firstResult = await queryForFirstResult(patchGraph, `${PREFIXES} - SELECT ?insert ?delete ?where WHERE { - ?patch a solid:InsertDeletePatch. - OPTIONAL { ?patch solid:inserts ?insert. } - OPTIONAL { ?patch solid:deletes ?delete. } - OPTIONAL { ?patch solid:where ?where. } - }`) - } catch (err) { - try { // deprecated, kept for compatibility - firstResult = await queryForFirstResult(patchGraph, `${PREFIXES} - SELECT ?insert ?delete ?where WHERE { - ?patch solid:patches <${targetURI}>. - OPTIONAL { ?patch solid:inserts ?insert. } - OPTIONAL { ?patch solid:deletes ?delete. } - OPTIONAL { ?patch solid:where ?where. } - }`) - } catch (err) { - throw error(400, 'No n3-patch found.', err) - } - } - - // Return the insertions and deletions as an rdflib patch document - const { '?insert': insert, '?delete': deleted, '?where': where } = firstResult - if (!insert && !deleted) { - throw error(400, 'Patch should at least contain inserts or deletes.') - } - return { insert, delete: deleted, where } -} - -// Queries the store with the given SPARQL query and returns the first result -function queryForFirstResult (store, sparql) { - return new Promise((resolve, reject) => { - const query = $rdf.SPARQLToQuery(sparql, false, store) - store.query(query, resolve, null, () => reject(new Error('No results.'))) - }) -} +// Parses a text/n3 patch + +import $rdf from 'rdflib' +import error from '../../http-error.js' + +const PATCH_NS = 'http://www.w3.org/ns/solid/terms#' +const PREFIXES = `PREFIX solid: <${PATCH_NS}>\n` + +// Parses the given N3 patch document +export default async function parsePatchDocument (targetURI, patchURI, patchText) { + // Parse the N3 document into triples + const patchGraph = $rdf.graph() + try { + $rdf.parse(patchText, patchGraph, patchURI, 'text/n3') + } catch (err) { + throw error(400, `Patch document syntax error: ${err}`) + } + + // Query the N3 document for insertions and deletions + let firstResult + try { // solid/protocol v0.9.0 + firstResult = await queryForFirstResult(patchGraph, `${PREFIXES} + SELECT ?insert ?delete ?where WHERE { + ?patch a solid:InsertDeletePatch. + OPTIONAL { ?patch solid:inserts ?insert. } + OPTIONAL { ?patch solid:deletes ?delete. } + OPTIONAL { ?patch solid:where ?where. } + }`) + } catch (err) { + try { // deprecated, kept for compatibility + firstResult = await queryForFirstResult(patchGraph, `${PREFIXES} + SELECT ?insert ?delete ?where WHERE { + ?patch solid:patches <${targetURI}>. + OPTIONAL { ?patch solid:inserts ?insert. } + OPTIONAL { ?patch solid:deletes ?delete. } + OPTIONAL { ?patch solid:where ?where. } + }`) + } catch (err) { + throw error(400, 'No n3-patch found.', err) + } + } + + // Return the insertions and deletions as an rdflib patch document + const { '?insert': insert, '?delete': deleted, '?where': where } = firstResult + if (!insert && !deleted) { + throw error(400, 'Patch should at least contain inserts or deletes.') + } + return { insert, delete: deleted, where } +} + +// Queries the store with the given SPARQL query and returns the first result +function queryForFirstResult (store, sparql) { + return new Promise((resolve, reject) => { + const query = $rdf.SPARQLToQuery(sparql, false, store) + store.query(query, resolve, null, () => reject(new Error('No results.'))) + }) +} diff --git a/lib/handlers/patch/sparql-update-parser.mjs b/lib/handlers/patch/sparql-update-parser.js similarity index 88% rename from lib/handlers/patch/sparql-update-parser.mjs rename to lib/handlers/patch/sparql-update-parser.js index 3dbbfcd32..2f5ec8ba5 100644 --- a/lib/handlers/patch/sparql-update-parser.mjs +++ b/lib/handlers/patch/sparql-update-parser.js @@ -1,14 +1,14 @@ -// Parses an application/sparql-update patch - -import $rdf from 'rdflib' -import error from '../../http-error.mjs' - -// Parses the given SPARQL UPDATE document -export default async function parsePatchDocument (targetURI, patchURI, patchText) { - const baseURI = patchURI.replace(/#.*/, '') - try { - return $rdf.sparqlUpdateParser(patchText, $rdf.graph(), baseURI) - } catch (err) { - throw error(400, `Patch document syntax error: ${err}`) - } -} +// Parses an application/sparql-update patch + +import $rdf from 'rdflib' +import error from '../../http-error.js' + +// Parses the given SPARQL UPDATE document +export default async function parsePatchDocument (targetURI, patchURI, patchText) { + const baseURI = patchURI.replace(/#.*/, '') + try { + return $rdf.sparqlUpdateParser(patchText, $rdf.graph(), baseURI) + } catch (err) { + throw error(400, `Patch document syntax error: ${err}`) + } +} diff --git a/lib/handlers/post.mjs b/lib/handlers/post.js similarity index 92% rename from lib/handlers/post.mjs rename to lib/handlers/post.js index d9a6781a6..2f38b1121 100644 --- a/lib/handlers/post.mjs +++ b/lib/handlers/post.js @@ -1,101 +1,101 @@ -import Busboy from '@fastify/busboy' -import debugModule from 'debug' -import path from 'path' -import * as header from '../header.mjs' -import patch from './patch.mjs' -import HTTPError from '../http-error.mjs' -import mime from 'mime-types' -import { getContentType } from '../utils.mjs' -const debug = debugModule('solid:post') - -export default async function handler (req, res, next) { - const { extensions } = mime - const ldp = req.app.locals.ldp - const contentType = getContentType(req.headers) - debug('content-type is ', contentType) - // Handle SPARQL(-update?) query - if (contentType === 'application/sparql' || - contentType === 'application/sparql-update') { - debug('switching to sparql query') - return patch(req, res, next) - } - - // Handle container path - let containerPath = req.path - if (containerPath[containerPath.length - 1] !== '/') { - containerPath += '/' - } - - // Check if container exists - let stats - try { - const ret = await ldp.exists(req.hostname, containerPath, false) - if (ret) stats = ret.stream - } catch (err) { - return next(HTTPError(err, 'Container not valid')) - } - - // Check if container is a directory - if (stats && !stats.isDirectory()) { - debug('Path is not a container, 405!') - return next(HTTPError(405, 'Requested resource is not a container')) - } - - // Dispatch to the right handler - if (req.is('multipart/form-data')) { - multi() - } else { - one() - } - - function multi () { - debug('receving multiple files') - - const busboy = new Busboy({ headers: req.headers }) - busboy.on('file', async function (fieldname, file, filename, encoding, mimetype) { - debug('One file received via multipart: ' + filename) - const { url: putUrl } = await ldp.resourceMapper.mapFileToUrl( - { path: ldp.resourceMapper._rootPath + path.join(containerPath, filename), hostname: req.hostname }) - try { - await ldp.put(putUrl, file, mimetype) - } catch (err) { - busboy.emit('error', err) - } - }) - busboy.on('error', function (err) { - debug('Error receiving the file: ' + err.message) - next(HTTPError(500, 'Error receiving the file')) - }) - - // Handled by backpressure of streams! - busboy.on('finish', function () { - debug('Done storing files') - res.sendStatus(200) - next() - }) - req.pipe(busboy) - } - - function one () { - debug('Receving one file') - const { slug, link, 'content-type': contentType } = req.headers - const links = header.parseMetadataFromHeader(link) - const mimeType = contentType ? contentType.replace(/\s*;.*/, '') : '' - const extension = mimeType in extensions ? `.${extensions[mimeType][0]}` : '' - debug('slug ' + slug) - debug('extension ' + extension) - debug('containerPath ' + containerPath) - debug('contentType ' + contentType) - debug('links ' + JSON.stringify(links)) - ldp.post(req.hostname, containerPath, req, - { slug, extension, container: links.isBasicContainer, contentType }).then( - resourcePath => { - debug('File stored in ' + resourcePath) - header.addLinks(res, links) - res.set('Location', resourcePath) - res.sendStatus(201) - next() - }, - err => next(err)) - } -} +import Busboy from '@fastify/busboy' +import debugModule from 'debug' +import path from 'path' +import * as header from '../header.js' +import patch from './patch.js' +import HTTPError from '../http-error.js' +import mime from 'mime-types' +import { getContentType } from '../utils.js' +const debug = debugModule('solid:post') + +export default async function handler (req, res, next) { + const { extensions } = mime + const ldp = req.app.locals.ldp + const contentType = getContentType(req.headers) + debug('content-type is ', contentType) + // Handle SPARQL(-update?) query + if (contentType === 'application/sparql' || + contentType === 'application/sparql-update') { + debug('switching to sparql query') + return patch(req, res, next) + } + + // Handle container path + let containerPath = req.path + if (containerPath[containerPath.length - 1] !== '/') { + containerPath += '/' + } + + // Check if container exists + let stats + try { + const ret = await ldp.exists(req.hostname, containerPath, false) + if (ret) stats = ret.stream + } catch (err) { + return next(HTTPError(err, 'Container not valid')) + } + + // Check if container is a directory + if (stats && !stats.isDirectory()) { + debug('Path is not a container, 405!') + return next(HTTPError(405, 'Requested resource is not a container')) + } + + // Dispatch to the right handler + if (req.is('multipart/form-data')) { + multi() + } else { + one() + } + + function multi () { + debug('receving multiple files') + + const busboy = new Busboy({ headers: req.headers }) + busboy.on('file', async function (fieldname, file, filename, encoding, mimetype) { + debug('One file received via multipart: ' + filename) + const { url: putUrl } = await ldp.resourceMapper.mapFileToUrl( + { path: ldp.resourceMapper._rootPath + path.join(containerPath, filename), hostname: req.hostname }) + try { + await ldp.put(putUrl, file, mimetype) + } catch (err) { + busboy.emit('error', err) + } + }) + busboy.on('error', function (err) { + debug('Error receiving the file: ' + err.message) + next(HTTPError(500, 'Error receiving the file')) + }) + + // Handled by backpressure of streams! + busboy.on('finish', function () { + debug('Done storing files') + res.sendStatus(200) + next() + }) + req.pipe(busboy) + } + + function one () { + debug('Receving one file') + const { slug, link, 'content-type': contentType } = req.headers + const links = header.parseMetadataFromHeader(link) + const mimeType = contentType ? contentType.replace(/\s*;.*/, '') : '' + const extension = mimeType in extensions ? `.${extensions[mimeType][0]}` : '' + debug('slug ' + slug) + debug('extension ' + extension) + debug('containerPath ' + containerPath) + debug('contentType ' + contentType) + debug('links ' + JSON.stringify(links)) + ldp.post(req.hostname, containerPath, req, + { slug, extension, container: links.isBasicContainer, contentType }).then( + resourcePath => { + debug('File stored in ' + resourcePath) + header.addLinks(res, links) + res.set('Location', resourcePath) + res.sendStatus(201) + next() + }, + err => next(err)) + } +} diff --git a/lib/handlers/put.mjs b/lib/handlers/put.js similarity index 94% rename from lib/handlers/put.mjs rename to lib/handlers/put.js index 8640791e6..4d51282fc 100644 --- a/lib/handlers/put.mjs +++ b/lib/handlers/put.js @@ -1,102 +1,102 @@ -import bodyParser from 'body-parser' -import { getContentType, stringToStream } from '../utils.mjs' -import HTTPError from '../http-error.mjs' -import debug from '../debug.mjs' - -export default async function handler (req, res, next) { - debug.handlers('PUT -- ' + req.originalUrl) - // deprecated kept for compatibility - res.header('MS-Author-Via', 'SPARQL') // is this needed ? - const contentType = req.get('content-type') - - // check whether a folder or resource with same name exists - try { - const ldp = req.app.locals.ldp - await ldp.checkItemName(req) - } catch (e) { - return next(e) - } - // check for valid rdf content for auxiliary resource and /profile/card - // TODO check that /profile/card is a minimal valid WebID card - if (isAuxiliary(req) || req.originalUrl === '/profile/card') { - if (contentType === 'text/turtle') { - return bodyParser.text({ type: () => true })(req, res, () => putValidRdf(req, res, next)) - } else return next(new HTTPError(415, 'RDF file contains invalid syntax')) - } - return putStream(req, res, next) -} - -// Verifies whether the user is allowed to perform Append PUT on the target -async function checkPermission (request, resourceExists) { - // If no ACL object was passed down, assume permissions are okay. - if (!request.acl) return Promise.resolve() - // At this point, we already assume append access, - // we might need to perform additional checks. - let modes = [] - // acl:default Write is required for PUT when Resource Exists - if (resourceExists) modes = ['Write'] - // if (resourceExists && request.originalUrl.endsWith('.acl')) modes = ['Control'] - const { acl, session: { userId } } = request - - const allowed = await Promise.all(modes.map(mode => acl.can(userId, mode, request.method, resourceExists))) - const allAllowed = allowed.reduce((memo, allowed) => memo && allowed, true) - if (!allAllowed) { - // check owner with Control - // const ldp = request.app.locals.ldp - // if (request.path.endsWith('.acl') && userId === await ldp.getOwner(request.hostname)) return Promise.resolve() - - const errors = await Promise.all(modes.map(mode => acl.getError(userId, mode))) - const error = errors.filter(error => !!error) - .reduce((prevErr, err) => prevErr.status > err.status ? prevErr : err, { status: 0 }) - return Promise.reject(error) - } - return Promise.resolve() -} - -// TODO could be renamed as putResource (it now covers container and non-container) -async function putStream (req, res, next, stream = req) { - const ldp = req.app.locals.ldp - // Obtain details of the target resource - let resourceExists = true - try { - // First check if the file already exists - await ldp.resourceMapper.mapUrlToFile({ url: req }) - // Fails on if-none-match asterisk precondition - if ((req.headers['if-none-match'] === '*') && !req.path.endsWith('/')) { - res.sendStatus(412) - return next() - } - } catch (err) { - resourceExists = false - } - try { - // Fails with Append on existing resource - if (!req.originalUrl.endsWith('.acl')) await checkPermission(req, resourceExists) - await ldp.put(req, stream, getContentType(req.headers)) - res.sendStatus(resourceExists ? 204 : 201) - return next() - } catch (err) { - err.message = 'Can\'t write file/folder: ' + err.message - return next(err) - } -} - -// needed to avoid breaking access with bad acl -// or breaking containement triples for meta -function putValidRdf (req, res, next) { - const ldp = req.app.locals.ldp - const contentType = req.get('content-type') - const requestUri = ldp.resourceMapper.getRequestUrl(req) - - if (ldp.isValidRdf(req.body, requestUri, contentType)) { - const stream = stringToStream(req.body) - return putStream(req, res, next, stream) - } - next(new HTTPError(400, 'RDF file contains invalid syntax')) -} - -function isAuxiliary (req) { - const originalUrlParts = req.originalUrl.split('.') - const ext = originalUrlParts[originalUrlParts.length - 1] - return (ext === 'acl' || ext === 'meta') +import bodyParser from 'body-parser' +import { getContentType, stringToStream } from '../utils.js' +import HTTPError from '../http-error.js' +import debug from '../debug.js' + +export default async function handler (req, res, next) { + debug.handlers('PUT -- ' + req.originalUrl) + // deprecated kept for compatibility + res.header('MS-Author-Via', 'SPARQL') // is this needed ? + const contentType = req.get('content-type') + + // check whether a folder or resource with same name exists + try { + const ldp = req.app.locals.ldp + await ldp.checkItemName(req) + } catch (e) { + return next(e) + } + // check for valid rdf content for auxiliary resource and /profile/card + // TODO check that /profile/card is a minimal valid WebID card + if (isAuxiliary(req) || req.originalUrl === '/profile/card') { + if (contentType === 'text/turtle') { + return bodyParser.text({ type: () => true })(req, res, () => putValidRdf(req, res, next)) + } else return next(new HTTPError(415, 'RDF file contains invalid syntax')) + } + return putStream(req, res, next) +} + +// Verifies whether the user is allowed to perform Append PUT on the target +async function checkPermission (request, resourceExists) { + // If no ACL object was passed down, assume permissions are okay. + if (!request.acl) return Promise.resolve() + // At this point, we already assume append access, + // we might need to perform additional checks. + let modes = [] + // acl:default Write is required for PUT when Resource Exists + if (resourceExists) modes = ['Write'] + // if (resourceExists && request.originalUrl.endsWith('.acl')) modes = ['Control'] + const { acl, session: { userId } } = request + + const allowed = await Promise.all(modes.map(mode => acl.can(userId, mode, request.method, resourceExists))) + const allAllowed = allowed.reduce((memo, allowed) => memo && allowed, true) + if (!allAllowed) { + // check owner with Control + // const ldp = request.app.locals.ldp + // if (request.path.endsWith('.acl') && userId === await ldp.getOwner(request.hostname)) return Promise.resolve() + + const errors = await Promise.all(modes.map(mode => acl.getError(userId, mode))) + const error = errors.filter(error => !!error) + .reduce((prevErr, err) => prevErr.status > err.status ? prevErr : err, { status: 0 }) + return Promise.reject(error) + } + return Promise.resolve() +} + +// TODO could be renamed as putResource (it now covers container and non-container) +async function putStream (req, res, next, stream = req) { + const ldp = req.app.locals.ldp + // Obtain details of the target resource + let resourceExists = true + try { + // First check if the file already exists + await ldp.resourceMapper.mapUrlToFile({ url: req }) + // Fails on if-none-match asterisk precondition + if ((req.headers['if-none-match'] === '*') && !req.path.endsWith('/')) { + res.sendStatus(412) + return next() + } + } catch (err) { + resourceExists = false + } + try { + // Fails with Append on existing resource + if (!req.originalUrl.endsWith('.acl')) await checkPermission(req, resourceExists) + await ldp.put(req, stream, getContentType(req.headers)) + res.sendStatus(resourceExists ? 204 : 201) + return next() + } catch (err) { + err.message = 'Can\'t write file/folder: ' + err.message + return next(err) + } +} + +// needed to avoid breaking access with bad acl +// or breaking containement triples for meta +function putValidRdf (req, res, next) { + const ldp = req.app.locals.ldp + const contentType = req.get('content-type') + const requestUri = ldp.resourceMapper.getRequestUrl(req) + + if (ldp.isValidRdf(req.body, requestUri, contentType)) { + const stream = stringToStream(req.body) + return putStream(req, res, next, stream) + } + next(new HTTPError(400, 'RDF file contains invalid syntax')) +} + +function isAuxiliary (req) { + const originalUrlParts = req.originalUrl.split('.') + const ext = originalUrlParts[originalUrlParts.length - 1] + return (ext === 'acl' || ext === 'meta') } diff --git a/lib/handlers/restrict-to-top-domain.mjs b/lib/handlers/restrict-to-top-domain.js similarity index 91% rename from lib/handlers/restrict-to-top-domain.mjs rename to lib/handlers/restrict-to-top-domain.js index a32e59f64..8008c8c94 100644 --- a/lib/handlers/restrict-to-top-domain.mjs +++ b/lib/handlers/restrict-to-top-domain.js @@ -1,4 +1,4 @@ -import HTTPError from '../http-error.mjs' +import HTTPError from '../http-error.js' export default function (req, res, next) { const locals = req.app.locals diff --git a/lib/header.mjs b/lib/header.js similarity index 94% rename from lib/header.mjs rename to lib/header.js index ed748623e..b6a8a7963 100644 --- a/lib/header.mjs +++ b/lib/header.js @@ -1,138 +1,138 @@ -import li from 'li' -import path from 'path' -import metadata from './metadata.mjs' -import debug from './debug.mjs' -import { pathBasename } from './utils.mjs' -import HTTPError from './http-error.mjs' - -const MODES = ['Read', 'Write', 'Append', 'Control'] -const PERMISSIONS = MODES.map(m => m.toLowerCase()) - -export function addLink (res, value, rel) { - const oldLink = res.get('Link') - if (oldLink === undefined) { - res.set('Link', '<' + value + '>; rel="' + rel + '"') - } else { - res.set('Link', oldLink + ', ' + '<' + value + '>; rel="' + rel + '"') - } -} - -export function addLinks (res, fileMetadata) { - if (fileMetadata.isResource) { - addLink(res, 'http://www.w3.org/ns/ldp#Resource', 'type') - } - if (fileMetadata.isSourceResource) { - addLink(res, 'http://www.w3.org/ns/ldp#RDFSource', 'type') - } - if (fileMetadata.isContainer) { - addLink(res, 'http://www.w3.org/ns/ldp#Container', 'type') - } - if (fileMetadata.isBasicContainer) { - addLink(res, 'http://www.w3.org/ns/ldp#BasicContainer', 'type') - } - if (fileMetadata.isDirectContainer) { - addLink(res, 'http://www.w3.org/ns/ldp#DirectContainer', 'type') - } - if (fileMetadata.isStorage) { - addLink(res, 'http://www.w3.org/ns/pim/space#Storage', 'type') - } -} - -export async function linksHandler (req, res, next) { - const ldp = req.app.locals.ldp - let filename - try { - // Hack: createIfNotExists is set to true for PUT or PATCH requests - // because the file might not exist yet at this point. - // But it will be created afterwards. - // This should be improved with the new server architecture. - ({ path: filename } = await ldp.resourceMapper - .mapUrlToFile({ url: req, createIfNotExists: req.method === 'PUT' || req.method === 'PATCH' })) - } catch (e) { - // Silently ignore errors here - // Later handlers will error as well, but they will be able to given a more concrete error message (like 400 or 404) - return next() - } - - if (path.extname(filename) === ldp.suffixMeta) { - debug.metadata('Trying to access metadata file as regular file.') - - return next(HTTPError(404, 'Trying to access metadata file as regular file')) - } - const fileMetadata = new metadata.Metadata() - if (req.path.endsWith('/')) { - // do not add storage header in serverUri - if (req.path === '/') fileMetadata.isStorage = true - fileMetadata.isContainer = true - fileMetadata.isBasicContainer = true - } else { - fileMetadata.isResource = true - } - // Add LDP-required Accept-Post header for OPTIONS request to containers - if (fileMetadata.isContainer && req.method === 'OPTIONS') { - res.header('Accept-Post', '*/*') - } - // Add ACL and Meta Link in header - addLink(res, pathBasename(req.path) + ldp.suffixAcl, 'acl') - addLink(res, pathBasename(req.path) + ldp.suffixMeta, 'describedBy') - // Add other Link headers - addLinks(res, fileMetadata) - next() -} - -export function parseMetadataFromHeader (linkHeader) { - const fileMetadata = new metadata.Metadata() - if (linkHeader === undefined) { - return fileMetadata - } - const links = linkHeader.split(',') - for (const linkIndex in links) { - const link = links[linkIndex] - const parsedLinks = li.parse(link) - for (const rel in parsedLinks) { - if (rel === 'type') { - if (parsedLinks[rel] === 'http://www.w3.org/ns/ldp#Resource') { - fileMetadata.isResource = true - } else if (parsedLinks[rel] === 'http://www.w3.org/ns/ldp#RDFSource') { - fileMetadata.isSourceResource = true - } else if (parsedLinks[rel] === 'http://www.w3.org/ns/ldp#Container') { - fileMetadata.isContainer = true - } else if (parsedLinks[rel] === 'http://www.w3.org/ns/ldp#BasicContainer') { - fileMetadata.isBasicContainer = true - } else if (parsedLinks[rel] === 'http://www.w3.org/ns/ldp#DirectContainer') { - fileMetadata.isDirectContainer = true - } else if (parsedLinks[rel] === 'http://www.w3.org/ns/pim/space#Storage') { - fileMetadata.isStorage = true - } - } - } - } - return fileMetadata -} - -// Adds a header that describes the user's permissions -export async function addPermissions (req, res, next) { - const { acl, session } = req - if (!acl) return next() - - // Turn permissions for the public and the user into a header - const ldp = req.app.locals.ldp - const resource = ldp.resourceMapper.resolveUrl(req.hostname, req.path) - let [publicPerms, userPerms] = await Promise.all([ - getPermissionsFor(acl, null, req), - getPermissionsFor(acl, session.userId, req) - ]) - if (resource.endsWith('.acl') && userPerms === '' && await ldp.isOwner(session.userId, req.hostname)) userPerms = 'control' - debug.ACL(`Permissions on ${resource} for ${session.userId || '(none)'}: ${userPerms}`) - debug.ACL(`Permissions on ${resource} for public: ${publicPerms}`) - // Set the header - res.set('WAC-Allow', `user="${userPerms}",public="${publicPerms}"`) - next() -} - -// Gets the permissions string for the given user and resource -async function getPermissionsFor (acl, user, req) { - const accesses = MODES.map(mode => acl.can(user, mode)) - const allowed = await Promise.all(accesses) - return PERMISSIONS.filter((mode, i) => allowed[i]).join(' ') -} +import li from 'li' +import path from 'path' +import metadata from './metadata.js' +import debug from './debug.js' +import { pathBasename } from './utils.js' +import HTTPError from './http-error.js' + +const MODES = ['Read', 'Write', 'Append', 'Control'] +const PERMISSIONS = MODES.map(m => m.toLowerCase()) + +export function addLink (res, value, rel) { + const oldLink = res.get('Link') + if (oldLink === undefined) { + res.set('Link', '<' + value + '>; rel="' + rel + '"') + } else { + res.set('Link', oldLink + ', ' + '<' + value + '>; rel="' + rel + '"') + } +} + +export function addLinks (res, fileMetadata) { + if (fileMetadata.isResource) { + addLink(res, 'http://www.w3.org/ns/ldp#Resource', 'type') + } + if (fileMetadata.isSourceResource) { + addLink(res, 'http://www.w3.org/ns/ldp#RDFSource', 'type') + } + if (fileMetadata.isContainer) { + addLink(res, 'http://www.w3.org/ns/ldp#Container', 'type') + } + if (fileMetadata.isBasicContainer) { + addLink(res, 'http://www.w3.org/ns/ldp#BasicContainer', 'type') + } + if (fileMetadata.isDirectContainer) { + addLink(res, 'http://www.w3.org/ns/ldp#DirectContainer', 'type') + } + if (fileMetadata.isStorage) { + addLink(res, 'http://www.w3.org/ns/pim/space#Storage', 'type') + } +} + +export async function linksHandler (req, res, next) { + const ldp = req.app.locals.ldp + let filename + try { + // Hack: createIfNotExists is set to true for PUT or PATCH requests + // because the file might not exist yet at this point. + // But it will be created afterwards. + // This should be improved with the new server architecture. + ({ path: filename } = await ldp.resourceMapper + .mapUrlToFile({ url: req, createIfNotExists: req.method === 'PUT' || req.method === 'PATCH' })) + } catch (e) { + // Silently ignore errors here + // Later handlers will error as well, but they will be able to given a more concrete error message (like 400 or 404) + return next() + } + + if (path.extname(filename) === ldp.suffixMeta) { + debug.metadata('Trying to access metadata file as regular file.') + + return next(HTTPError(404, 'Trying to access metadata file as regular file')) + } + const fileMetadata = new metadata.Metadata() + if (req.path.endsWith('/')) { + // do not add storage header in serverUri + if (req.path === '/') fileMetadata.isStorage = true + fileMetadata.isContainer = true + fileMetadata.isBasicContainer = true + } else { + fileMetadata.isResource = true + } + // Add LDP-required Accept-Post header for OPTIONS request to containers + if (fileMetadata.isContainer && req.method === 'OPTIONS') { + res.header('Accept-Post', '*/*') + } + // Add ACL and Meta Link in header + addLink(res, pathBasename(req.path) + ldp.suffixAcl, 'acl') + addLink(res, pathBasename(req.path) + ldp.suffixMeta, 'describedBy') + // Add other Link headers + addLinks(res, fileMetadata) + next() +} + +export function parseMetadataFromHeader (linkHeader) { + const fileMetadata = new metadata.Metadata() + if (linkHeader === undefined) { + return fileMetadata + } + const links = linkHeader.split(',') + for (const linkIndex in links) { + const link = links[linkIndex] + const parsedLinks = li.parse(link) + for (const rel in parsedLinks) { + if (rel === 'type') { + if (parsedLinks[rel] === 'http://www.w3.org/ns/ldp#Resource') { + fileMetadata.isResource = true + } else if (parsedLinks[rel] === 'http://www.w3.org/ns/ldp#RDFSource') { + fileMetadata.isSourceResource = true + } else if (parsedLinks[rel] === 'http://www.w3.org/ns/ldp#Container') { + fileMetadata.isContainer = true + } else if (parsedLinks[rel] === 'http://www.w3.org/ns/ldp#BasicContainer') { + fileMetadata.isBasicContainer = true + } else if (parsedLinks[rel] === 'http://www.w3.org/ns/ldp#DirectContainer') { + fileMetadata.isDirectContainer = true + } else if (parsedLinks[rel] === 'http://www.w3.org/ns/pim/space#Storage') { + fileMetadata.isStorage = true + } + } + } + } + return fileMetadata +} + +// Adds a header that describes the user's permissions +export async function addPermissions (req, res, next) { + const { acl, session } = req + if (!acl) return next() + + // Turn permissions for the public and the user into a header + const ldp = req.app.locals.ldp + const resource = ldp.resourceMapper.resolveUrl(req.hostname, req.path) + let [publicPerms, userPerms] = await Promise.all([ + getPermissionsFor(acl, null, req), + getPermissionsFor(acl, session.userId, req) + ]) + if (resource.endsWith('.acl') && userPerms === '' && await ldp.isOwner(session.userId, req.hostname)) userPerms = 'control' + debug.ACL(`Permissions on ${resource} for ${session.userId || '(none)'}: ${userPerms}`) + debug.ACL(`Permissions on ${resource} for public: ${publicPerms}`) + // Set the header + res.set('WAC-Allow', `user="${userPerms}",public="${publicPerms}"`) + next() +} + +// Gets the permissions string for the given user and resource +async function getPermissionsFor (acl, user, req) { + const accesses = MODES.map(mode => acl.can(user, mode)) + const allowed = await Promise.all(accesses) + return PERMISSIONS.filter((mode, i) => allowed[i]).join(' ') +} diff --git a/lib/http-error.mjs b/lib/http-error.js similarity index 96% rename from lib/http-error.mjs rename to lib/http-error.js index 29b5873fd..46c633a36 100644 --- a/lib/http-error.mjs +++ b/lib/http-error.js @@ -1,35 +1,35 @@ -import { inherits } from 'util' - -export default function HTTPError (status, message) { - if (!(this instanceof HTTPError)) { - return new HTTPError(status, message) - } - - // Error.captureStackTrace(this, this.constructor) - this.name = this.constructor.name - - // If status is an object it will be of the form: - // {status: , message: } - if (typeof status === 'number') { - this.message = message || 'Error occurred' - this.status = status - } else { - const err = status - let _status - let _code - let _message - if (err && err.status) { - _status = err.status - } - if (err && err.code) { - _code = err.code - } - if (err && err.message) { - _message = err.message - } - this.message = message || _message - this.status = _status || _code === 'ENOENT' ? 404 : 500 - } -} - +import { inherits } from 'util' + +export default function HTTPError (status, message) { + if (!(this instanceof HTTPError)) { + return new HTTPError(status, message) + } + + // Error.captureStackTrace(this, this.constructor) + this.name = this.constructor.name + + // If status is an object it will be of the form: + // {status: , message: } + if (typeof status === 'number') { + this.message = message || 'Error occurred' + this.status = status + } else { + const err = status + let _status + let _code + let _message + if (err && err.status) { + _status = err.status + } + if (err && err.code) { + _code = err.code + } + if (err && err.message) { + _message = err.message + } + this.message = message || _message + this.status = _status || _code === 'ENOENT' ? 404 : 500 + } +} + inherits(HTTPError, Error) diff --git a/lib/ldp-container.mjs b/lib/ldp-container.js similarity index 98% rename from lib/ldp-container.mjs rename to lib/ldp-container.js index aeff5ff59..37db2dab2 100644 --- a/lib/ldp-container.mjs +++ b/lib/ldp-container.js @@ -1,6 +1,6 @@ import $rdf from 'rdflib' -import debug from './debug.mjs' -import error from './http-error.mjs' +import debug from './debug.js' +import error from './http-error.js' import fs from 'fs' import vocab from 'solid-namespace' import mime from 'mime-types' diff --git a/lib/ldp-copy.mjs b/lib/ldp-copy.js similarity index 93% rename from lib/ldp-copy.mjs rename to lib/ldp-copy.js index 8925b699f..7a7af6e03 100644 --- a/lib/ldp-copy.mjs +++ b/lib/ldp-copy.js @@ -1,82 +1,82 @@ -import debugModule from './debug.mjs' -import fs from 'fs' -import { ensureDir } from 'fs-extra' -import HTTPError from './http-error.mjs' -import path from 'path' -import http from 'http' -import https from 'https' -import { getContentType } from './utils.mjs' - -const debug = debugModule.handlers - -/** - * Cleans up a file write stream (ends stream, deletes the file). - * @method cleanupFileStream - * @private - * @param stream {WriteStream} - */ -function cleanupFileStream (stream) { - const streamPath = stream.path - stream.destroy() - fs.unlinkSync(streamPath) -} - -/** - * Performs an LDP Copy operation, imports a remote resource to a local path. - * @param resourceMapper {ResourceMapper} A resource mapper instance. - * @param copyToUri {Object} The location (in the current domain) to copy to. - * @param copyFromUri {String} Location of remote resource to copy from - * @return A promise resolving when the copy operation is finished - */ -export default function copy (resourceMapper, copyToUri, copyFromUri) { - return new Promise((resolve, reject) => { - const request = /^https:/.test(copyFromUri) ? https : http - - const options = { - rejectUnauthorized: false // Allow self-signed certificates for internal requests - } - - request.get(copyFromUri, options) - .on('error', function (err) { - debug('COPY -- Error requesting source file: ' + err) - this.end() - return reject(new Error('Error writing data: ' + err)) - }) - .on('response', function (response) { - if (response.statusCode !== 200) { - debug('COPY -- HTTP error reading source file: ' + response.statusMessage) - this.end() - const error = new Error('Error reading source file: ' + response.statusMessage) - error.statusCode = response.statusCode - return reject(error) - } - // Grab the content type from the source - const contentType = getContentType(response.headers) - resourceMapper.mapUrlToFile({ url: copyToUri, createIfNotExists: true, contentType }) - .then(({ path: copyToPath }) => { - ensureDir(path.dirname(copyToPath)) - .then(() => { - const destinationStream = fs.createWriteStream(copyToPath) - .on('error', function (err) { - cleanupFileStream(this) - return reject(new Error('Error writing data: ' + err)) - }) - .on('finish', function () { - // Success - debug('COPY -- Wrote data to: ' + copyToPath) - resolve() - }) - response.pipe(destinationStream) - }) - .catch(err => { - debug('COPY -- Error creating destination directory: ' + err) - return reject(new Error('Failed to create the path to the destination resource: ' + err)) - }) - }) - .catch((err) => { - debug('COPY -- mapUrlToFile error: ' + err) - reject(HTTPError(500, 'Could not find target file to copy')) - }) - }) - }) -} +import debugModule from './debug.js' +import fs from 'fs' +import { ensureDir } from 'fs-extra' +import HTTPError from './http-error.js' +import path from 'path' +import http from 'http' +import https from 'https' +import { getContentType } from './utils.js' + +const debug = debugModule.handlers + +/** + * Cleans up a file write stream (ends stream, deletes the file). + * @method cleanupFileStream + * @private + * @param stream {WriteStream} + */ +function cleanupFileStream (stream) { + const streamPath = stream.path + stream.destroy() + fs.unlinkSync(streamPath) +} + +/** + * Performs an LDP Copy operation, imports a remote resource to a local path. + * @param resourceMapper {ResourceMapper} A resource mapper instance. + * @param copyToUri {Object} The location (in the current domain) to copy to. + * @param copyFromUri {String} Location of remote resource to copy from + * @return A promise resolving when the copy operation is finished + */ +export default function copy (resourceMapper, copyToUri, copyFromUri) { + return new Promise((resolve, reject) => { + const request = /^https:/.test(copyFromUri) ? https : http + + const options = { + rejectUnauthorized: false // Allow self-signed certificates for internal requests + } + + request.get(copyFromUri, options) + .on('error', function (err) { + debug('COPY -- Error requesting source file: ' + err) + this.end() + return reject(new Error('Error writing data: ' + err)) + }) + .on('response', function (response) { + if (response.statusCode !== 200) { + debug('COPY -- HTTP error reading source file: ' + response.statusMessage) + this.end() + const error = new Error('Error reading source file: ' + response.statusMessage) + error.statusCode = response.statusCode + return reject(error) + } + // Grab the content type from the source + const contentType = getContentType(response.headers) + resourceMapper.mapUrlToFile({ url: copyToUri, createIfNotExists: true, contentType }) + .then(({ path: copyToPath }) => { + ensureDir(path.dirname(copyToPath)) + .then(() => { + const destinationStream = fs.createWriteStream(copyToPath) + .on('error', function (err) { + cleanupFileStream(this) + return reject(new Error('Error writing data: ' + err)) + }) + .on('finish', function () { + // Success + debug('COPY -- Wrote data to: ' + copyToPath) + resolve() + }) + response.pipe(destinationStream) + }) + .catch(err => { + debug('COPY -- Error creating destination directory: ' + err) + return reject(new Error('Failed to create the path to the destination resource: ' + err)) + }) + }) + .catch((err) => { + debug('COPY -- mapUrlToFile error: ' + err) + reject(HTTPError(500, 'Could not find target file to copy')) + }) + }) + }) +} diff --git a/lib/ldp-middleware.mjs b/lib/ldp-middleware.js similarity index 59% rename from lib/ldp-middleware.mjs rename to lib/ldp-middleware.js index e0a2826ae..403680afe 100644 --- a/lib/ldp-middleware.mjs +++ b/lib/ldp-middleware.js @@ -1,38 +1,38 @@ -import express from 'express' -import { linksHandler, addPermissions } from './header.mjs' -import allow from './handlers/allow.mjs' -import get from './handlers/get.mjs' -import post from './handlers/post.mjs' -import put from './handlers/put.mjs' -import del from './handlers/delete.mjs' -import patch from './handlers/patch.mjs' -import index from './handlers/index.mjs' -import copy from './handlers/copy.mjs' -import notify from './handlers/notify.mjs' - -export default function LdpMiddleware (corsSettings, prep) { - const router = express.Router('/') - - // Add Link headers - router.use(linksHandler) - - if (corsSettings) { - router.use(corsSettings) - } - - router.copy('/*', allow('Write'), copy) - router.get('/*', index, allow('Read'), addPermissions, get) - router.post('/*', allow('Append'), post) - router.patch('/*', allow('Append'), patch) - router.put('/*', allow('Append'), put) - router.delete('/*', allow('Write'), del) - - if (prep) { - router.post('/*', notify) - router.patch('/*', notify) - router.put('/*', notify) - router.delete('/*', notify) - } - - return router -} +import express from 'express' +import { linksHandler, addPermissions } from './header.js' +import allow from './handlers/allow.js' +import get from './handlers/get.js' +import post from './handlers/post.js' +import put from './handlers/put.js' +import del from './handlers/delete.js' +import patch from './handlers/patch.js' +import index from './handlers/index.js' +import copy from './handlers/copy.js' +import notify from './handlers/notify.js' + +export default function LdpMiddleware (corsSettings, prep) { + const router = express.Router('/') + + // Add Link headers + router.use(linksHandler) + + if (corsSettings) { + router.use(corsSettings) + } + + router.copy('/*', allow('Write'), copy) + router.get('/*', index, allow('Read'), addPermissions, get) + router.post('/*', allow('Append'), post) + router.patch('/*', allow('Append'), patch) + router.put('/*', allow('Append'), put) + router.delete('/*', allow('Write'), del) + + if (prep) { + router.post('/*', notify) + router.patch('/*', notify) + router.put('/*', notify) + router.delete('/*', notify) + } + + return router +} diff --git a/lib/ldp.mjs b/lib/ldp.js similarity index 99% rename from lib/ldp.mjs rename to lib/ldp.js index 83dc25904..3814861b0 100644 --- a/lib/ldp.mjs +++ b/lib/ldp.js @@ -5,16 +5,16 @@ import fs from 'fs' import $rdf from 'rdflib' import { mkdirp } from 'fs-extra' import { v4 as uuid } from 'uuid' // there seem to be an esm module -import debug from './debug.mjs' -import error from './http-error.mjs' -import { stringToStream, serialize, overQuota, getContentType, parse } from './utils.mjs' +import debug from './debug.js' +import error from './http-error.js' +import { stringToStream, serialize, overQuota, getContentType, parse } from './utils.js' import extend from 'extend' import rimraf from 'rimraf' import { exec } from 'child_process' -import * as ldpContainer from './ldp-container.mjs' +import * as ldpContainer from './ldp-container.js' import { promisify } from 'util' -import withLock from './lock.mjs' -import { clearAclCache } from './acl-checker.mjs' +import withLock from './lock.js' +import { clearAclCache } from './acl-checker.js' const RDF_MIME_TYPES = new Set([ 'text/turtle', // .ttl diff --git a/lib/lock.mjs b/lib/lock.js similarity index 96% rename from lib/lock.mjs rename to lib/lock.js index 9e72a0844..4c8174d90 100644 --- a/lib/lock.mjs +++ b/lib/lock.js @@ -1,10 +1,10 @@ -import AsyncLock from 'async-lock' - -const lock = new AsyncLock({ timeout: 30 * 1000 }) - -// Obtains a lock on the path, and maintains it until the task finishes -async function withLock (path, executeTask) { - return await lock.acquire(path, executeTask) -} - +import AsyncLock from 'async-lock' + +const lock = new AsyncLock({ timeout: 30 * 1000 }) + +// Obtains a lock on the path, and maintains it until the task finishes +async function withLock (path, executeTask) { + return await lock.acquire(path, executeTask) +} + export default withLock diff --git a/lib/metadata.mjs b/lib/metadata.js similarity index 96% rename from lib/metadata.mjs rename to lib/metadata.js index e90e33e93..6d7d0ef77 100644 --- a/lib/metadata.mjs +++ b/lib/metadata.js @@ -1,11 +1,11 @@ -export function Metadata () { - this.filename = '' - this.isResource = false - this.isSourceResource = false - this.isContainer = false - this.isBasicContainer = false - this.isDirectContainer = false - this.isStorage = false -} - +export function Metadata () { + this.filename = '' + this.isResource = false + this.isSourceResource = false + this.isContainer = false + this.isBasicContainer = false + this.isDirectContainer = false + this.isStorage = false +} + export default { Metadata } diff --git a/lib/models/account-manager.mjs b/lib/models/account-manager.js similarity index 93% rename from lib/models/account-manager.mjs rename to lib/models/account-manager.js index b204b6439..e8e0a39af 100644 --- a/lib/models/account-manager.mjs +++ b/lib/models/account-manager.js @@ -1,297 +1,297 @@ -import { URL } from 'url' -import rdf from 'rdflib' -import vocab from 'solid-namespace' -import defaults from '../../config/defaults.mjs' -import UserAccount from './user-account.mjs' -import AccountTemplate, { TEMPLATE_EXTENSIONS, TEMPLATE_FILES } from './account-template.mjs' -import debugModule from './../debug.mjs' -const ns = vocab(rdf) - -const debug = debugModule.accounts -const DEFAULT_PROFILE_CONTENT_TYPE = 'text/turtle' -const DEFAULT_ADMIN_USERNAME = 'admin' - -class AccountManager { - constructor (options = {}) { - if (!options.host) { - throw Error('AccountManager requires a host instance') - } - this.host = options.host - this.emailService = options.emailService - this.tokenService = options.tokenService - this.authMethod = options.authMethod || defaults.auth - this.multiuser = options.multiuser || false - this.store = options.store - this.pathCard = options.pathCard || 'profile/card' - this.suffixURI = options.suffixURI || '#me' - this.accountTemplatePath = options.accountTemplatePath || './default-templates/new-account/' - } - - static from (options) { - return new AccountManager(options) - } - - accountExists (accountName) { - let accountUri - let cardPath - try { - accountUri = this.accountUriFor(accountName) - accountUri = new URL(accountUri).hostname - // `pathCard` is a path fragment like 'profile/card' -> ensure it starts with '/' - cardPath = this.pathCard && this.pathCard.startsWith('/') ? this.pathCard : '/' + this.pathCard - } catch (err) { - return Promise.reject(err) - } - return this.accountUriExists(accountUri, cardPath) - } - - async accountUriExists (accountUri, accountResource = '/') { - try { - return await this.store.exists(accountUri, accountResource) - } catch (err) { - return false - } - } - - accountDirFor (accountName) { - const { hostname } = new URL(this.accountUriFor(accountName)) - return this.store.resourceMapper.resolveFilePath(hostname) - } - - accountUriFor (accountName) { - const accountUri = this.multiuser - ? this.host.accountUriFor(accountName) - : this.host.serverUri - return accountUri - } - - accountWebIdFor (accountName) { - const accountUri = this.accountUriFor(accountName) - const webIdUri = new URL(this.pathCard, accountUri) - webIdUri.hash = this.suffixURI - return webIdUri.toString() - } - - rootAclFor (userAccount) { - const accountUri = this.accountUriFor(userAccount.username) - return new URL(this.store.suffixAcl, accountUri).toString() - } - - addCertKeyToProfile (certificate, userAccount) { - if (!certificate) { - throw new TypeError('Cannot add empty certificate to user profile') - } - return this.getProfileGraphFor(userAccount) - .then(profileGraph => this.addCertKeyToGraph(certificate, profileGraph)) - .then(profileGraph => this.saveProfileGraph(profileGraph, userAccount)) - } - - getProfileGraphFor (userAccount, contentType = DEFAULT_PROFILE_CONTENT_TYPE) { - const webId = userAccount.webId - if (!webId) { - const error = new Error('Cannot fetch profile graph, missing WebId URI') - error.status = 400 - return Promise.reject(error) - } - const uri = userAccount.profileUri - return this.store.getGraph(uri, contentType) - .catch(error => { - error.message = `Error retrieving profile graph ${uri}: ` + error.message - throw error - }) - } - - saveProfileGraph (profileGraph, userAccount, contentType = DEFAULT_PROFILE_CONTENT_TYPE) { - const webId = userAccount.webId - if (!webId) { - const error = new Error('Cannot save profile graph, missing WebId URI') - error.status = 400 - return Promise.reject(error) - } - const uri = userAccount.profileUri - return this.store.putGraph(profileGraph, uri, contentType) - } - - addCertKeyToGraph (certificate, graph) { - const webId = rdf.namedNode(certificate.webId) - const key = rdf.namedNode(certificate.keyUri) - const timeCreated = rdf.literal(certificate.date.toISOString(), ns.xsd('dateTime')) - const modulus = rdf.literal(certificate.modulus, ns.xsd('hexBinary')) - const exponent = rdf.literal(certificate.exponent, ns.xsd('int')) - const title = rdf.literal('Created by solid-server') - const label = rdf.literal(certificate.commonName) - graph.add(webId, ns.cert('key'), key) - graph.add(key, ns.rdf('type'), ns.cert('RSAPublicKey')) - graph.add(key, ns.dct('title'), title) - graph.add(key, ns.rdfs('label'), label) - graph.add(key, ns.dct('created'), timeCreated) - graph.add(key, ns.cert('modulus'), modulus) - graph.add(key, ns.cert('exponent'), exponent) - return graph - } - - userAccountFrom (userData) { - const userConfig = { - username: userData.username, - email: userData.email, - name: userData.name, - externalWebId: userData.externalWebId, - localAccountId: userData.localAccountId, - webId: userData.webid || userData.webId || userData.externalWebId, - idp: this.host.serverUri - } - if (userConfig.username) { - userConfig.username = userConfig.username.toLowerCase() - } - try { - userConfig.webId = userConfig.webId || this.accountWebIdFor(userConfig.username) - } catch (err) { - if (err.message === 'Cannot construct uri for blank account name') { - throw new Error('Username or web id is required') - } else { - throw err - } - } - if (userConfig.username) { - if (userConfig.externalWebId && !userConfig.localAccountId) { - userConfig.localAccountId = this.accountWebIdFor(userConfig.username) - .split('//')[1] - } - } else { - if (userConfig.externalWebId) { - userConfig.username = userConfig.externalWebId - } else { - userConfig.username = this.usernameFromWebId(userConfig.webId) - } - } - return UserAccount.from(userConfig) - } - - usernameFromWebId (webId) { - if (!this.multiuser) { - return DEFAULT_ADMIN_USERNAME - } - const profileUrl = new URL(webId) - const hostname = profileUrl.hostname - return hostname.split('.')[0] - } - - createAccountFor (userAccount) { - const template = AccountTemplate.for(userAccount) - const templatePath = this.accountTemplatePath - const accountDir = this.accountDirFor(userAccount.username) - debug(`Creating account folder for ${userAccount.webId} at ${accountDir}`) - return AccountTemplate.copyTemplateDir(templatePath, accountDir) - .then(() => template.processAccount(accountDir)) - } - - generateResetToken (userAccount) { - return this.tokenService.generate('reset-password', { webId: userAccount.webId }) - } - - generateDeleteToken (userAccount) { - return this.tokenService.generate('delete-account.mjs', { - webId: userAccount.webId, - email: userAccount.email - }) - } - - validateDeleteToken (token) { - const tokenValue = this.tokenService.verify('delete-account.mjs', token) - if (!tokenValue) { - throw new Error('Invalid or expired delete account token') - } - return tokenValue - } - - validateResetToken (token) { - const tokenValue = this.tokenService.verify('reset-password', token) - if (!tokenValue) { - throw new Error('Invalid or expired reset token') - } - return tokenValue - } - - passwordResetUrl (token, returnToUrl) { - let resetUrl = new URL(`/account/password/change?token=${token}`, this.host.serverUri).toString() - if (returnToUrl) { - resetUrl += `&returnToUrl=${returnToUrl}` - } - return resetUrl - } - - getAccountDeleteUrl (token) { - return new URL(`/account/delete/confirm?token=${token}`, this.host.serverUri).toString() - } - - loadAccountRecoveryEmail (userAccount) { - return Promise.resolve() - .then(() => { - const rootAclUri = this.rootAclFor(userAccount) - return this.store.getGraph(rootAclUri) - }) - .then(rootAclGraph => { - const matches = rootAclGraph.match(null, ns.acl('agent')) - let recoveryMailto = matches.find(agent => agent.object.value.startsWith('mailto:')) - if (recoveryMailto) { - recoveryMailto = recoveryMailto.object.value.replace('mailto:', '') - } - return recoveryMailto - }) - } - - verifyEmailDependencies (userAccount) { - if (!this.emailService) { - throw new Error('Email service is not set up') - } - if (userAccount && !userAccount.email) { - throw new Error('Account recovery email has not been provided') - } - } - - sendDeleteAccountEmail (userAccount) { - return Promise.resolve() - .then(() => this.verifyEmailDependencies(userAccount)) - .then(() => this.generateDeleteToken(userAccount)) - .then(resetToken => { - const deleteUrl = this.getAccountDeleteUrl(resetToken) - const emailData = { - to: userAccount.email, - webId: userAccount.webId, - deleteUrl: deleteUrl - } - return this.emailService.sendWithTemplate('delete-account.mjs', emailData) - }) - } - - sendPasswordResetEmail (userAccount, returnToUrl) { - return Promise.resolve() - .then(() => this.verifyEmailDependencies(userAccount)) - .then(() => this.generateResetToken(userAccount)) - .then(resetToken => { - const resetUrl = this.passwordResetUrl(resetToken, returnToUrl) - const emailData = { - to: userAccount.email, - webId: userAccount.webId, - resetUrl - } - return this.emailService.sendWithTemplate('reset-password.mjs', emailData) - }) - } - - sendWelcomeEmail (newUser) { - const emailService = this.emailService - if (!emailService || !newUser.email) { - return Promise.resolve(null) - } - const emailData = { - to: newUser.email, - webid: newUser.webId, - name: newUser.displayName - } - return emailService.sendWithTemplate('welcome.mjs', emailData) - } -} - -export default AccountManager -export { TEMPLATE_EXTENSIONS, TEMPLATE_FILES } +import { URL } from 'url' +import rdf from 'rdflib' +import vocab from 'solid-namespace' +import defaults from '../../config/defaults.js' +import UserAccount from './user-account.js' +import AccountTemplate, { TEMPLATE_EXTENSIONS, TEMPLATE_FILES } from './account-template.js' +import debugModule from './../debug.js' +const ns = vocab(rdf) + +const debug = debugModule.accounts +const DEFAULT_PROFILE_CONTENT_TYPE = 'text/turtle' +const DEFAULT_ADMIN_USERNAME = 'admin' + +class AccountManager { + constructor (options = {}) { + if (!options.host) { + throw Error('AccountManager requires a host instance') + } + this.host = options.host + this.emailService = options.emailService + this.tokenService = options.tokenService + this.authMethod = options.authMethod || defaults.auth + this.multiuser = options.multiuser || false + this.store = options.store + this.pathCard = options.pathCard || 'profile/card' + this.suffixURI = options.suffixURI || '#me' + this.accountTemplatePath = options.accountTemplatePath || './default-templates/new-account/' + } + + static from (options) { + return new AccountManager(options) + } + + accountExists (accountName) { + let accountUri + let cardPath + try { + accountUri = this.accountUriFor(accountName) + accountUri = new URL(accountUri).hostname + // `pathCard` is a path fragment like 'profile/card' -> ensure it starts with '/' + cardPath = this.pathCard && this.pathCard.startsWith('/') ? this.pathCard : '/' + this.pathCard + } catch (err) { + return Promise.reject(err) + } + return this.accountUriExists(accountUri, cardPath) + } + + async accountUriExists (accountUri, accountResource = '/') { + try { + return await this.store.exists(accountUri, accountResource) + } catch (err) { + return false + } + } + + accountDirFor (accountName) { + const { hostname } = new URL(this.accountUriFor(accountName)) + return this.store.resourceMapper.resolveFilePath(hostname) + } + + accountUriFor (accountName) { + const accountUri = this.multiuser + ? this.host.accountUriFor(accountName) + : this.host.serverUri + return accountUri + } + + accountWebIdFor (accountName) { + const accountUri = this.accountUriFor(accountName) + const webIdUri = new URL(this.pathCard, accountUri) + webIdUri.hash = this.suffixURI + return webIdUri.toString() + } + + rootAclFor (userAccount) { + const accountUri = this.accountUriFor(userAccount.username) + return new URL(this.store.suffixAcl, accountUri).toString() + } + + addCertKeyToProfile (certificate, userAccount) { + if (!certificate) { + throw new TypeError('Cannot add empty certificate to user profile') + } + return this.getProfileGraphFor(userAccount) + .then(profileGraph => this.addCertKeyToGraph(certificate, profileGraph)) + .then(profileGraph => this.saveProfileGraph(profileGraph, userAccount)) + } + + getProfileGraphFor (userAccount, contentType = DEFAULT_PROFILE_CONTENT_TYPE) { + const webId = userAccount.webId + if (!webId) { + const error = new Error('Cannot fetch profile graph, missing WebId URI') + error.status = 400 + return Promise.reject(error) + } + const uri = userAccount.profileUri + return this.store.getGraph(uri, contentType) + .catch(error => { + error.message = `Error retrieving profile graph ${uri}: ` + error.message + throw error + }) + } + + saveProfileGraph (profileGraph, userAccount, contentType = DEFAULT_PROFILE_CONTENT_TYPE) { + const webId = userAccount.webId + if (!webId) { + const error = new Error('Cannot save profile graph, missing WebId URI') + error.status = 400 + return Promise.reject(error) + } + const uri = userAccount.profileUri + return this.store.putGraph(profileGraph, uri, contentType) + } + + addCertKeyToGraph (certificate, graph) { + const webId = rdf.namedNode(certificate.webId) + const key = rdf.namedNode(certificate.keyUri) + const timeCreated = rdf.literal(certificate.date.toISOString(), ns.xsd('dateTime')) + const modulus = rdf.literal(certificate.modulus, ns.xsd('hexBinary')) + const exponent = rdf.literal(certificate.exponent, ns.xsd('int')) + const title = rdf.literal('Created by solid-server') + const label = rdf.literal(certificate.commonName) + graph.add(webId, ns.cert('key'), key) + graph.add(key, ns.rdf('type'), ns.cert('RSAPublicKey')) + graph.add(key, ns.dct('title'), title) + graph.add(key, ns.rdfs('label'), label) + graph.add(key, ns.dct('created'), timeCreated) + graph.add(key, ns.cert('modulus'), modulus) + graph.add(key, ns.cert('exponent'), exponent) + return graph + } + + userAccountFrom (userData) { + const userConfig = { + username: userData.username, + email: userData.email, + name: userData.name, + externalWebId: userData.externalWebId, + localAccountId: userData.localAccountId, + webId: userData.webid || userData.webId || userData.externalWebId, + idp: this.host.serverUri + } + if (userConfig.username) { + userConfig.username = userConfig.username.toLowerCase() + } + try { + userConfig.webId = userConfig.webId || this.accountWebIdFor(userConfig.username) + } catch (err) { + if (err.message === 'Cannot construct uri for blank account name') { + throw new Error('Username or web id is required') + } else { + throw err + } + } + if (userConfig.username) { + if (userConfig.externalWebId && !userConfig.localAccountId) { + userConfig.localAccountId = this.accountWebIdFor(userConfig.username) + .split('//')[1] + } + } else { + if (userConfig.externalWebId) { + userConfig.username = userConfig.externalWebId + } else { + userConfig.username = this.usernameFromWebId(userConfig.webId) + } + } + return UserAccount.from(userConfig) + } + + usernameFromWebId (webId) { + if (!this.multiuser) { + return DEFAULT_ADMIN_USERNAME + } + const profileUrl = new URL(webId) + const hostname = profileUrl.hostname + return hostname.split('.')[0] + } + + createAccountFor (userAccount) { + const template = AccountTemplate.for(userAccount) + const templatePath = this.accountTemplatePath + const accountDir = this.accountDirFor(userAccount.username) + debug(`Creating account folder for ${userAccount.webId} at ${accountDir}`) + return AccountTemplate.copyTemplateDir(templatePath, accountDir) + .then(() => template.processAccount(accountDir)) + } + + generateResetToken (userAccount) { + return this.tokenService.generate('reset-password', { webId: userAccount.webId }) + } + + generateDeleteToken (userAccount) { + return this.tokenService.generate('delete-account.js', { + webId: userAccount.webId, + email: userAccount.email + }) + } + + validateDeleteToken (token) { + const tokenValue = this.tokenService.verify('delete-account.js', token) + if (!tokenValue) { + throw new Error('Invalid or expired delete account token') + } + return tokenValue + } + + validateResetToken (token) { + const tokenValue = this.tokenService.verify('reset-password', token) + if (!tokenValue) { + throw new Error('Invalid or expired reset token') + } + return tokenValue + } + + passwordResetUrl (token, returnToUrl) { + let resetUrl = new URL(`/account/password/change?token=${token}`, this.host.serverUri).toString() + if (returnToUrl) { + resetUrl += `&returnToUrl=${returnToUrl}` + } + return resetUrl + } + + getAccountDeleteUrl (token) { + return new URL(`/account/delete/confirm?token=${token}`, this.host.serverUri).toString() + } + + loadAccountRecoveryEmail (userAccount) { + return Promise.resolve() + .then(() => { + const rootAclUri = this.rootAclFor(userAccount) + return this.store.getGraph(rootAclUri) + }) + .then(rootAclGraph => { + const matches = rootAclGraph.match(null, ns.acl('agent')) + let recoveryMailto = matches.find(agent => agent.object.value.startsWith('mailto:')) + if (recoveryMailto) { + recoveryMailto = recoveryMailto.object.value.replace('mailto:', '') + } + return recoveryMailto + }) + } + + verifyEmailDependencies (userAccount) { + if (!this.emailService) { + throw new Error('Email service is not set up') + } + if (userAccount && !userAccount.email) { + throw new Error('Account recovery email has not been provided') + } + } + + sendDeleteAccountEmail (userAccount) { + return Promise.resolve() + .then(() => this.verifyEmailDependencies(userAccount)) + .then(() => this.generateDeleteToken(userAccount)) + .then(resetToken => { + const deleteUrl = this.getAccountDeleteUrl(resetToken) + const emailData = { + to: userAccount.email, + webId: userAccount.webId, + deleteUrl: deleteUrl + } + return this.emailService.sendWithTemplate('delete-account.js', emailData) + }) + } + + sendPasswordResetEmail (userAccount, returnToUrl) { + return Promise.resolve() + .then(() => this.verifyEmailDependencies(userAccount)) + .then(() => this.generateResetToken(userAccount)) + .then(resetToken => { + const resetUrl = this.passwordResetUrl(resetToken, returnToUrl) + const emailData = { + to: userAccount.email, + webId: userAccount.webId, + resetUrl + } + return this.emailService.sendWithTemplate('reset-password.js', emailData) + }) + } + + sendWelcomeEmail (newUser) { + const emailService = this.emailService + if (!emailService || !newUser.email) { + return Promise.resolve(null) + } + const emailData = { + to: newUser.email, + webid: newUser.webId, + name: newUser.displayName + } + return emailService.sendWithTemplate('welcome.js', emailData) + } +} + +export default AccountManager +export { TEMPLATE_EXTENSIONS, TEMPLATE_FILES } diff --git a/lib/models/account-template.mjs b/lib/models/account-template.js similarity index 91% rename from lib/models/account-template.mjs rename to lib/models/account-template.js index d01262895..681dbbe0e 100644 --- a/lib/models/account-template.mjs +++ b/lib/models/account-template.js @@ -1,70 +1,70 @@ -import path from 'path' -import mime from 'mime-types' -import recursiveRead from 'recursive-readdir' -import * as fsUtils from '../common/fs-utils.mjs' -import * as templateUtils from '../common/template-utils.mjs' -import LDP from '../ldp.mjs' -import { URL } from 'url' - -export const TEMPLATE_EXTENSIONS = ['.acl', '.meta', '.json', '.hbs', '.handlebars'] -export const TEMPLATE_FILES = ['card'] - -class AccountTemplate { - constructor (options = {}) { - this.substitutions = options.substitutions || {} - this.templateExtensions = options.templateExtensions || TEMPLATE_EXTENSIONS - this.templateFiles = options.templateFiles || TEMPLATE_FILES - } - - static for (userAccount, options = {}) { - const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) - options = Object.assign({ substitutions }, options) - return new AccountTemplate(options) - } - - static copyTemplateDir (templatePath, accountPath) { - return fsUtils.copyTemplateDir(templatePath, accountPath) - } - - static templateSubstitutionsFor (userAccount) { - const webUri = new URL(userAccount.webId) - const podRelWebId = userAccount.webId.replace(webUri.origin, '') - const substitutions = { - name: userAccount.displayName, - webId: userAccount.externalWebId ? userAccount.webId : podRelWebId, - email: userAccount.email, - idp: userAccount.idp - } - return substitutions - } - - readAccountFiles (accountPath) { - return new Promise((resolve, reject) => { - recursiveRead(accountPath, (error, files) => { - if (error) { return reject(error) } - resolve(files) - }) - }) - } - - readTemplateFiles (accountPath) { - return this.readAccountFiles(accountPath) - .then(files => files.filter((file) => this.isTemplate(file))) - } - - processAccount (accountPath) { - return this.readTemplateFiles(accountPath) - .then(files => Promise.all(files.map(path => templateUtils.processHandlebarFile(path, this.substitutions)))) - } - - isTemplate (filePath) { - const parsed = path.parse(filePath) - const mimeType = mime.lookup(filePath) - const isRdf = LDP.mimeTypeIsRdf(mimeType) - const isTemplateExtension = this.templateExtensions.includes(parsed.ext) - const isTemplateFile = this.templateFiles.includes(parsed.base) || this.templateExtensions.includes(parsed.base) - return isRdf || isTemplateExtension || isTemplateFile - } -} - -export default AccountTemplate +import path from 'path' +import mime from 'mime-types' +import recursiveRead from 'recursive-readdir' +import * as fsUtils from '../common/fs-utils.js' +import * as templateUtils from '../common/template-utils.js' +import LDP from '../ldp.js' +import { URL } from 'url' + +export const TEMPLATE_EXTENSIONS = ['.acl', '.meta', '.json', '.hbs', '.handlebars'] +export const TEMPLATE_FILES = ['card'] + +class AccountTemplate { + constructor (options = {}) { + this.substitutions = options.substitutions || {} + this.templateExtensions = options.templateExtensions || TEMPLATE_EXTENSIONS + this.templateFiles = options.templateFiles || TEMPLATE_FILES + } + + static for (userAccount, options = {}) { + const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) + options = Object.assign({ substitutions }, options) + return new AccountTemplate(options) + } + + static copyTemplateDir (templatePath, accountPath) { + return fsUtils.copyTemplateDir(templatePath, accountPath) + } + + static templateSubstitutionsFor (userAccount) { + const webUri = new URL(userAccount.webId) + const podRelWebId = userAccount.webId.replace(webUri.origin, '') + const substitutions = { + name: userAccount.displayName, + webId: userAccount.externalWebId ? userAccount.webId : podRelWebId, + email: userAccount.email, + idp: userAccount.idp + } + return substitutions + } + + readAccountFiles (accountPath) { + return new Promise((resolve, reject) => { + recursiveRead(accountPath, (error, files) => { + if (error) { return reject(error) } + resolve(files) + }) + }) + } + + readTemplateFiles (accountPath) { + return this.readAccountFiles(accountPath) + .then(files => files.filter((file) => this.isTemplate(file))) + } + + processAccount (accountPath) { + return this.readTemplateFiles(accountPath) + .then(files => Promise.all(files.map(path => templateUtils.processHandlebarFile(path, this.substitutions)))) + } + + isTemplate (filePath) { + const parsed = path.parse(filePath) + const mimeType = mime.lookup(filePath) + const isRdf = LDP.mimeTypeIsRdf(mimeType) + const isTemplateExtension = this.templateExtensions.includes(parsed.ext) + const isTemplateFile = this.templateFiles.includes(parsed.base) || this.templateExtensions.includes(parsed.base) + return isRdf || isTemplateExtension || isTemplateFile + } +} + +export default AccountTemplate diff --git a/lib/models/authenticator.mjs b/lib/models/authenticator.js similarity index 94% rename from lib/models/authenticator.mjs rename to lib/models/authenticator.js index 72056b807..de39f9925 100644 --- a/lib/models/authenticator.mjs +++ b/lib/models/authenticator.js @@ -1,161 +1,161 @@ -import debugModule from './../debug.mjs' -import validUrl from 'valid-url' -import * as webid from '../webid/tls/index.mjs' -import provider from '@solid/oidc-auth-manager/src/preferred-provider.js' -import oidcManager from '@solid/oidc-auth-manager/src/oidc-manager.js' -const { domainMatches } = oidcManager - -const debug = debugModule.authentication - -export class Authenticator { - constructor (options) { - this.accountManager = options.accountManager - } - - static fromParams (req, options) { - throw new Error('Must override method') - } - - findValidUser () { - throw new Error('Must override method') - } -} - -export class PasswordAuthenticator extends Authenticator { - constructor (options) { - super(options) - this.userStore = options.userStore - this.username = options.username - this.password = options.password - } - - static fromParams (req, options) { - const body = req.body || {} - options.username = body.username - options.password = body.password - return new PasswordAuthenticator(options) - } - - validate () { - let error - if (!this.username) { - error = new Error('Username required') - error.statusCode = 400 - throw error - } - if (!this.password) { - error = new Error('Password required') - error.statusCode = 400 - throw error - } - } - - findValidUser () { - let error - let userOptions - return Promise.resolve() - .then(() => this.validate()) - .then(() => { - if (validUrl.isUri(this.username)) { - userOptions = { webId: this.username } - } else { - userOptions = { username: this.username } - } - const user = this.accountManager.userAccountFrom(userOptions) - debug(`Attempting to login user: ${user.id}`) - return this.userStore.findUser(user.id) - }) - .then(foundUser => { - if (!foundUser) { - error = new Error('Invalid username/password combination.') - error.statusCode = 400 - throw error - } - if (foundUser.link) { - throw new Error('Linked users not currently supported, sorry (external WebID without TLS?)') - } - return this.userStore.matchPassword(foundUser, this.password) - }) - .then(validUser => { - if (!validUser) { - error = new Error('Invalid username/password combination.') - error.statusCode = 400 - throw error - } - debug('User found, password matches') - return this.accountManager.userAccountFrom(validUser) - }) - } -} - -export class TlsAuthenticator extends Authenticator { - constructor (options) { - super(options) - this.connection = options.connection - } - - static fromParams (req, options) { - options.connection = req.connection - return new TlsAuthenticator(options) - } - - findValidUser () { - return this.renegotiateTls() - .then(() => this.getCertificate()) - .then(cert => this.extractWebId(cert)) - .then(webId => this.loadUser(webId)) - } - - renegotiateTls () { - const connection = this.connection - return new Promise((resolve, reject) => { - connection.renegotiate({ requestCert: true, rejectUnauthorized: false }, (error) => { - if (error) { - debug('Error renegotiating TLS:', error) - return reject(error) - } - resolve() - }) - }) - } - - getCertificate () { - const certificate = this.connection.getPeerCertificate() - if (!certificate || !Object.keys(certificate).length) { - debug('No client certificate detected') - throw new Error('No client certificate detected. (You may need to restart your browser to retry.)') - } - return certificate - } - - extractWebId (certificate) { - return new Promise((resolve, reject) => { - this.verifyWebId(certificate, (error, webId) => { - if (error) { - debug('Error processing certificate:', error) - return reject(error) - } - resolve(webId) - }) - }) - } - - verifyWebId (certificate, callback) { - debug('Verifying WebID URI') - webid.verify(certificate, callback) - } - - discoverProviderFor (webId) { - return provider.discoverProviderFor(webId) - } - - loadUser (webId) { - const serverUri = this.accountManager.host.serverUri - if (domainMatches(serverUri, webId)) { - return this.accountManager.userAccountFrom({ webId }) - } else { - debug(`WebID URI ${JSON.stringify(webId)} is not a local account, using it as an external WebID`) - return this.accountManager.userAccountFrom({ webId, username: webId, externalWebId: true }) - } - } -} +import debugModule from './../debug.js' +import validUrl from 'valid-url' +import * as webid from '../webid/tls/index.js' +import provider from '@solid/oidc-auth-manager/src/preferred-provider.js' +import oidcManager from '@solid/oidc-auth-manager/src/oidc-manager.js' +const { domainMatches } = oidcManager + +const debug = debugModule.authentication + +export class Authenticator { + constructor (options) { + this.accountManager = options.accountManager + } + + static fromParams (req, options) { + throw new Error('Must override method') + } + + findValidUser () { + throw new Error('Must override method') + } +} + +export class PasswordAuthenticator extends Authenticator { + constructor (options) { + super(options) + this.userStore = options.userStore + this.username = options.username + this.password = options.password + } + + static fromParams (req, options) { + const body = req.body || {} + options.username = body.username + options.password = body.password + return new PasswordAuthenticator(options) + } + + validate () { + let error + if (!this.username) { + error = new Error('Username required') + error.statusCode = 400 + throw error + } + if (!this.password) { + error = new Error('Password required') + error.statusCode = 400 + throw error + } + } + + findValidUser () { + let error + let userOptions + return Promise.resolve() + .then(() => this.validate()) + .then(() => { + if (validUrl.isUri(this.username)) { + userOptions = { webId: this.username } + } else { + userOptions = { username: this.username } + } + const user = this.accountManager.userAccountFrom(userOptions) + debug(`Attempting to login user: ${user.id}`) + return this.userStore.findUser(user.id) + }) + .then(foundUser => { + if (!foundUser) { + error = new Error('Invalid username/password combination.') + error.statusCode = 400 + throw error + } + if (foundUser.link) { + throw new Error('Linked users not currently supported, sorry (external WebID without TLS?)') + } + return this.userStore.matchPassword(foundUser, this.password) + }) + .then(validUser => { + if (!validUser) { + error = new Error('Invalid username/password combination.') + error.statusCode = 400 + throw error + } + debug('User found, password matches') + return this.accountManager.userAccountFrom(validUser) + }) + } +} + +export class TlsAuthenticator extends Authenticator { + constructor (options) { + super(options) + this.connection = options.connection + } + + static fromParams (req, options) { + options.connection = req.connection + return new TlsAuthenticator(options) + } + + findValidUser () { + return this.renegotiateTls() + .then(() => this.getCertificate()) + .then(cert => this.extractWebId(cert)) + .then(webId => this.loadUser(webId)) + } + + renegotiateTls () { + const connection = this.connection + return new Promise((resolve, reject) => { + connection.renegotiate({ requestCert: true, rejectUnauthorized: false }, (error) => { + if (error) { + debug('Error renegotiating TLS:', error) + return reject(error) + } + resolve() + }) + }) + } + + getCertificate () { + const certificate = this.connection.getPeerCertificate() + if (!certificate || !Object.keys(certificate).length) { + debug('No client certificate detected') + throw new Error('No client certificate detected. (You may need to restart your browser to retry.)') + } + return certificate + } + + extractWebId (certificate) { + return new Promise((resolve, reject) => { + this.verifyWebId(certificate, (error, webId) => { + if (error) { + debug('Error processing certificate:', error) + return reject(error) + } + resolve(webId) + }) + }) + } + + verifyWebId (certificate, callback) { + debug('Verifying WebID URI') + webid.verify(certificate, callback) + } + + discoverProviderFor (webId) { + return provider.discoverProviderFor(webId) + } + + loadUser (webId) { + const serverUri = this.accountManager.host.serverUri + if (domainMatches(serverUri, webId)) { + return this.accountManager.userAccountFrom({ webId }) + } else { + debug(`WebID URI ${JSON.stringify(webId)} is not a local account, using it as an external WebID`) + return this.accountManager.userAccountFrom({ webId, username: webId, externalWebId: true }) + } + } +} diff --git a/lib/models/oidc-manager.mjs b/lib/models/oidc-manager.js similarity index 92% rename from lib/models/oidc-manager.mjs rename to lib/models/oidc-manager.js index 5cf69ce41..52670a115 100644 --- a/lib/models/oidc-manager.mjs +++ b/lib/models/oidc-manager.js @@ -1,22 +1,22 @@ -import { URL } from 'url' -import path from 'path' -import debug from '../debug.mjs' -import OidcManager from '@solid/oidc-auth-manager' - -export function fromServerConfig (argv) { - const providerUri = argv.host.serverUri - const authCallbackUri = new URL('/api/oidc/rp', providerUri).toString() - const postLogoutUri = new URL('/goodbye', providerUri).toString() - const dbPath = path.join(argv.dbPath, 'oidc') - const options = { - debug: debug.authentication, - providerUri, - dbPath, - authCallbackUri, - postLogoutUri, - saltRounds: argv.saltRounds, - delayBeforeRegisteringInitialClient: argv.delayBeforeRegisteringInitialClient, - host: { debug: debug.authentication } - } - return OidcManager.from(options) -} +import { URL } from 'url' +import path from 'path' +import debug from '../debug.js' +import OidcManager from '@solid/oidc-auth-manager' + +export function fromServerConfig (argv) { + const providerUri = argv.host.serverUri + const authCallbackUri = new URL('/api/oidc/rp', providerUri).toString() + const postLogoutUri = new URL('/goodbye', providerUri).toString() + const dbPath = path.join(argv.dbPath, 'oidc') + const options = { + debug: debug.authentication, + providerUri, + dbPath, + authCallbackUri, + postLogoutUri, + saltRounds: argv.saltRounds, + delayBeforeRegisteringInitialClient: argv.delayBeforeRegisteringInitialClient, + host: { debug: debug.authentication } + } + return OidcManager.from(options) +} diff --git a/lib/models/solid-host.mjs b/lib/models/solid-host.js similarity index 93% rename from lib/models/solid-host.mjs rename to lib/models/solid-host.js index 13a085f97..437f60f30 100644 --- a/lib/models/solid-host.mjs +++ b/lib/models/solid-host.js @@ -1,63 +1,63 @@ -import { URL } from 'url' -import defaults from '../../config/defaults.mjs' - -class SolidHost { - constructor (options = {}) { - this.port = options.port || defaults.port - this.serverUri = options.serverUri || defaults.serverUri - this.parsedUri = new URL(this.serverUri) - this.host = this.parsedUri.host - this.hostname = this.parsedUri.hostname - this.live = options.live - this.root = options.root - this.multiuser = options.multiuser - this.webid = options.webid - } - - static from (options = {}) { - return new SolidHost(options) - } - - accountUriFor (accountName) { - if (!accountName) { - throw TypeError('Cannot construct uri for blank account name') - } - if (!this.parsedUri) { - throw TypeError('Cannot construct account, host not initialized with serverUri') - } - return this.parsedUri.protocol + '//' + accountName + '.' + this.host - } - - allowsSessionFor (userId, origin, trustedOrigins) { - if (!userId || !origin) return true - const originHost = getHostName(origin) - const serverHost = getHostName(this.serverUri) - if (originHost === serverHost) return true - if (originHost.endsWith('.' + serverHost)) return true - const userHost = getHostName(userId) - if (originHost === userHost) return true - if (trustedOrigins.includes(origin)) return true - return false - } - - get authEndpoint () { - const authUrl = new URL('/authorize', this.serverUri) - // Return the WHATWG URL object - return authUrl - } - - get cookieDomain () { - let cookieDomain = null - if (this.hostname.split('.').length > 1) { - cookieDomain = '.' + this.hostname - } - return cookieDomain - } -} - -function getHostName (urlStr) { - const match = urlStr.match(/^\w+:\/*([^/]+)/) - return match ? match[1] : '' -} - -export default SolidHost +import { URL } from 'url' +import defaults from '../../config/defaults.js' + +class SolidHost { + constructor (options = {}) { + this.port = options.port || defaults.port + this.serverUri = options.serverUri || defaults.serverUri + this.parsedUri = new URL(this.serverUri) + this.host = this.parsedUri.host + this.hostname = this.parsedUri.hostname + this.live = options.live + this.root = options.root + this.multiuser = options.multiuser + this.webid = options.webid + } + + static from (options = {}) { + return new SolidHost(options) + } + + accountUriFor (accountName) { + if (!accountName) { + throw TypeError('Cannot construct uri for blank account name') + } + if (!this.parsedUri) { + throw TypeError('Cannot construct account, host not initialized with serverUri') + } + return this.parsedUri.protocol + '//' + accountName + '.' + this.host + } + + allowsSessionFor (userId, origin, trustedOrigins) { + if (!userId || !origin) return true + const originHost = getHostName(origin) + const serverHost = getHostName(this.serverUri) + if (originHost === serverHost) return true + if (originHost.endsWith('.' + serverHost)) return true + const userHost = getHostName(userId) + if (originHost === userHost) return true + if (trustedOrigins.includes(origin)) return true + return false + } + + get authEndpoint () { + const authUrl = new URL('/authorize', this.serverUri) + // Return the WHATWG URL object + return authUrl + } + + get cookieDomain () { + let cookieDomain = null + if (this.hostname.split('.').length > 1) { + cookieDomain = '.' + this.hostname + } + return cookieDomain + } +} + +function getHostName (urlStr) { + const match = urlStr.match(/^\w+:\/*([^/]+)/) + return match ? match[1] : '' +} + +export default SolidHost diff --git a/lib/models/user-account.mjs b/lib/models/user-account.js similarity index 95% rename from lib/models/user-account.mjs rename to lib/models/user-account.js index a77fa4503..10d1dbec8 100644 --- a/lib/models/user-account.mjs +++ b/lib/models/user-account.js @@ -1,50 +1,50 @@ -import { URL } from 'url' - -class UserAccount { - constructor (options = {}) { - this.username = options.username - this.webId = options.webId - this.name = options.name - this.email = options.email - this.externalWebId = options.externalWebId - this.localAccountId = options.localAccountId - this.idp = options.idp - } - - static from (options = {}) { - return new UserAccount(options) - } - - get displayName () { - return this.name || this.username || this.email || 'Solid account' - } - - get id () { - if (!this.webId) { return null } - const parsed = new URL(this.webId) - let id = parsed.host + parsed.pathname - if (parsed.hash) { - id += parsed.hash - } - return id - } - - get accountUri () { - if (!this.webId) { return null } - const parsed = new URL(this.webId) - return parsed.origin - } - - get podUri () { - const webIdUrl = new URL(this.webId) - return webIdUrl.origin - } - - get profileUri () { - if (!this.webId) { return null } - const parsed = new URL(this.webId) - return parsed.origin + parsed.pathname - } -} - -export default UserAccount +import { URL } from 'url' + +class UserAccount { + constructor (options = {}) { + this.username = options.username + this.webId = options.webId + this.name = options.name + this.email = options.email + this.externalWebId = options.externalWebId + this.localAccountId = options.localAccountId + this.idp = options.idp + } + + static from (options = {}) { + return new UserAccount(options) + } + + get displayName () { + return this.name || this.username || this.email || 'Solid account' + } + + get id () { + if (!this.webId) { return null } + const parsed = new URL(this.webId) + let id = parsed.host + parsed.pathname + if (parsed.hash) { + id += parsed.hash + } + return id + } + + get accountUri () { + if (!this.webId) { return null } + const parsed = new URL(this.webId) + return parsed.origin + } + + get podUri () { + const webIdUrl = new URL(this.webId) + return webIdUrl.origin + } + + get profileUri () { + if (!this.webId) { return null } + const parsed = new URL(this.webId) + return parsed.origin + parsed.pathname + } +} + +export default UserAccount diff --git a/lib/models/webid-tls-certificate.mjs b/lib/models/webid-tls-certificate.js similarity index 93% rename from lib/models/webid-tls-certificate.mjs rename to lib/models/webid-tls-certificate.js index 173d22428..fafeeaeb2 100644 --- a/lib/models/webid-tls-certificate.mjs +++ b/lib/models/webid-tls-certificate.js @@ -1,97 +1,97 @@ -import * as webidTls from '../webid/tls/index.mjs' -import forge from 'node-forge' -import * as utils from '../utils.mjs' - -class WebIdTlsCertificate { - constructor (options = {}) { - this.spkac = options.spkac - this.date = options.date || new Date() - this.webId = options.webId - this.commonName = options.commonName - this.issuer = { commonName: options.issuerName } - this.certificate = null - } - - static fromSpkacPost (spkac, userAccount, host) { - if (!spkac) { - const error = new TypeError('Missing spkac parameter') - error.status = 400 - throw error - } - const date = new Date() - const commonName = `${userAccount.displayName} [on ${host.serverUri}, created ${date}]` - const options = { - spkac: WebIdTlsCertificate.prepPublicKey(spkac), - webId: userAccount.webId, - date, - commonName, - issuerName: host.serverUri - } - return new WebIdTlsCertificate(options) - } - - static prepPublicKey (spkac) { - if (!spkac) { return null } - spkac = utils.stripLineEndings(spkac) - spkac = Buffer.from(spkac, 'utf-8') - return spkac - } - - generateCertificate () { - const certOptions = { - spkac: this.spkac, - agent: this.webId, - commonName: this.commonName, - issuer: this.issuer - } - return new Promise((resolve, reject) => { - webidTls.generate(certOptions, (err, certificate) => { - if (err) { - reject(err) - } else { - this.certificate = certificate - resolve(this) - } - }) - }) - } - - get keyUri () { - if (!this.webId) { - const error = new TypeError('Cannot construct key URI, WebID is missing') - error.status = 400 - throw error - } - const profileUri = this.webId.split('#')[0] - return profileUri + '#key-' + this.date.getTime() - } - - get exponent () { - if (!this.certificate) { - const error = new TypeError('Cannot return exponent, certificate has not been generated') - error.status = 400 - throw error - } - return this.certificate.publicKey.e.toString() - } - - get modulus () { - if (!this.certificate) { - const error = new TypeError('Cannot return modulus, certificate has not been generated') - error.status = 400 - throw error - } - return this.certificate.publicKey.n.toString(16).toUpperCase() - } - - toDER () { - if (!this.certificate) { - return null - } - const certificateAsn = forge.pki.certificateToAsn1(this.certificate) - const certificateDer = forge.asn1.toDer(certificateAsn).getBytes() - return certificateDer - } -} - -export default WebIdTlsCertificate +import * as webidTls from '../webid/tls/index.js' +import forge from 'node-forge' +import * as utils from '../utils.js' + +class WebIdTlsCertificate { + constructor (options = {}) { + this.spkac = options.spkac + this.date = options.date || new Date() + this.webId = options.webId + this.commonName = options.commonName + this.issuer = { commonName: options.issuerName } + this.certificate = null + } + + static fromSpkacPost (spkac, userAccount, host) { + if (!spkac) { + const error = new TypeError('Missing spkac parameter') + error.status = 400 + throw error + } + const date = new Date() + const commonName = `${userAccount.displayName} [on ${host.serverUri}, created ${date}]` + const options = { + spkac: WebIdTlsCertificate.prepPublicKey(spkac), + webId: userAccount.webId, + date, + commonName, + issuerName: host.serverUri + } + return new WebIdTlsCertificate(options) + } + + static prepPublicKey (spkac) { + if (!spkac) { return null } + spkac = utils.stripLineEndings(spkac) + spkac = Buffer.from(spkac, 'utf-8') + return spkac + } + + generateCertificate () { + const certOptions = { + spkac: this.spkac, + agent: this.webId, + commonName: this.commonName, + issuer: this.issuer + } + return new Promise((resolve, reject) => { + webidTls.generate(certOptions, (err, certificate) => { + if (err) { + reject(err) + } else { + this.certificate = certificate + resolve(this) + } + }) + }) + } + + get keyUri () { + if (!this.webId) { + const error = new TypeError('Cannot construct key URI, WebID is missing') + error.status = 400 + throw error + } + const profileUri = this.webId.split('#')[0] + return profileUri + '#key-' + this.date.getTime() + } + + get exponent () { + if (!this.certificate) { + const error = new TypeError('Cannot return exponent, certificate has not been generated') + error.status = 400 + throw error + } + return this.certificate.publicKey.e.toString() + } + + get modulus () { + if (!this.certificate) { + const error = new TypeError('Cannot return modulus, certificate has not been generated') + error.status = 400 + throw error + } + return this.certificate.publicKey.n.toString(16).toUpperCase() + } + + toDER () { + if (!this.certificate) { + return null + } + const certificateAsn = forge.pki.certificateToAsn1(this.certificate) + const certificateDer = forge.asn1.toDer(certificateAsn).getBytes() + return certificateDer + } +} + +export default WebIdTlsCertificate diff --git a/lib/payment-pointer-discovery.mjs b/lib/payment-pointer-discovery.js similarity index 100% rename from lib/payment-pointer-discovery.mjs rename to lib/payment-pointer-discovery.js diff --git a/lib/rdf-notification-template.mjs b/lib/rdf-notification-template.js similarity index 100% rename from lib/rdf-notification-template.mjs rename to lib/rdf-notification-template.js diff --git a/lib/requests/add-cert-request.mjs b/lib/requests/add-cert-request.js similarity index 95% rename from lib/requests/add-cert-request.mjs rename to lib/requests/add-cert-request.js index ff7b4339a..261cf6d6b 100644 --- a/lib/requests/add-cert-request.mjs +++ b/lib/requests/add-cert-request.js @@ -1,70 +1,70 @@ -import WebIdTlsCertificate from '../models/webid-tls-certificate.mjs' -import debugModule from '../debug.mjs' - -const debug = debugModule.accounts - -export default class AddCertificateRequest { - constructor (options) { - this.accountManager = options.accountManager - this.userAccount = options.userAccount - this.certificate = options.certificate - this.response = options.response +import WebIdTlsCertificate from '../models/webid-tls-certificate.js' +import debugModule from '../debug.js' + +const debug = debugModule.accounts + +export default class AddCertificateRequest { + constructor (options) { + this.accountManager = options.accountManager + this.userAccount = options.userAccount + this.certificate = options.certificate + this.response = options.response } - - static handle (req, res, accountManager) { - let request - try { - request = AddCertificateRequest.fromParams(req, res, accountManager) - } catch (error) { - return Promise.reject(error) - } - return AddCertificateRequest.addCertificate(request) + + static handle (req, res, accountManager) { + let request + try { + request = AddCertificateRequest.fromParams(req, res, accountManager) + } catch (error) { + return Promise.reject(error) + } + return AddCertificateRequest.addCertificate(request) } - - static fromParams (req, res, accountManager) { - const userAccount = accountManager.userAccountFrom(req.body) - const certificate = WebIdTlsCertificate.fromSpkacPost( - req.body.spkac, - userAccount, - accountManager.host - ) - debug(`Adding a new certificate for ${userAccount.webId}`) - if (req.session.userId !== userAccount.webId) { - debug(`Cannot add new certificate: signed in user is "${req.session.userId}"`) - const error = new Error("You are not logged in, so you can't create a certificate") - error.status = 401 - throw error - } - const options = { accountManager, userAccount, certificate, response: res } - return new AddCertificateRequest(options) + + static fromParams (req, res, accountManager) { + const userAccount = accountManager.userAccountFrom(req.body) + const certificate = WebIdTlsCertificate.fromSpkacPost( + req.body.spkac, + userAccount, + accountManager.host + ) + debug(`Adding a new certificate for ${userAccount.webId}`) + if (req.session.userId !== userAccount.webId) { + debug(`Cannot add new certificate: signed in user is "${req.session.userId}"`) + const error = new Error("You are not logged in, so you can't create a certificate") + error.status = 401 + throw error + } + const options = { accountManager, userAccount, certificate, response: res } + return new AddCertificateRequest(options) } - - static addCertificate (request) { - const { certificate, userAccount, accountManager } = request - return certificate.generateCertificate() - .catch(err => { - err.status = 400 - err.message = 'Error generating a certificate: ' + err.message - throw err - }) - .then(() => { - return accountManager.addCertKeyToProfile(certificate, userAccount) - }) - .catch(err => { - err.status = 400 - err.message = 'Error adding certificate to profile: ' + err.message - throw err - }) - .then(() => { - request.sendResponse(certificate) - }) + + static addCertificate (request) { + const { certificate, userAccount, accountManager } = request + return certificate.generateCertificate() + .catch(err => { + err.status = 400 + err.message = 'Error generating a certificate: ' + err.message + throw err + }) + .then(() => { + return accountManager.addCertKeyToProfile(certificate, userAccount) + }) + .catch(err => { + err.status = 400 + err.message = 'Error adding certificate to profile: ' + err.message + throw err + }) + .then(() => { + request.sendResponse(certificate) + }) } - - sendResponse (certificate) { - const { response, userAccount } = this - response.set('User', userAccount.webId) - response.status(200) - response.set('Content-Type', 'application/x-x509-user-cert') - response.send(certificate.toDER()) - } -} + + sendResponse (certificate) { + const { response, userAccount } = this + response.set('User', userAccount.webId) + response.status(200) + response.set('Content-Type', 'application/x-x509-user-cert') + response.send(certificate.toDER()) + } +} diff --git a/lib/requests/auth-request.mjs b/lib/requests/auth-request.js similarity index 96% rename from lib/requests/auth-request.mjs rename to lib/requests/auth-request.js index 8f34c6594..10b514652 100644 --- a/lib/requests/auth-request.mjs +++ b/lib/requests/auth-request.js @@ -1,151 +1,151 @@ -import { URL } from 'url' -import debugModule from '../debug.mjs' -import { createRequire } from 'module' - -// Helper: attach key/value pairs from `params` into URLSearchParams of `urlObj` -function attachQueryParams (urlObj, params) { - if (!params) return urlObj - for (const [k, v] of Object.entries(params)) { - if (v != null) urlObj.searchParams.set(k, v) - } - return urlObj -} - -// Avoid importing `@solid/oidc-op` at module-evaluation time to prevent -// import errors in environments where that package isn't resolvable. -// We'll try to require it lazily when needed. -const requireCjs = createRequire(import.meta.url) - -const debug = debugModule.authentication - -const AUTH_QUERY_PARAMS = [ - 'response_type', 'display', 'scope', - 'client_id', 'redirect_uri', 'state', 'nonce', 'request' -] - -export default class AuthRequest { - constructor (options) { - this.response = options.response - this.session = options.session || {} - this.userStore = options.userStore - this.accountManager = options.accountManager - this.returnToUrl = options.returnToUrl - this.authQueryParams = options.authQueryParams || {} - this.localAuth = options.localAuth - this.enforceToc = options.enforceToc - this.tocUri = options.tocUri - } - - static parseParameter (req, parameter) { - const query = req.query || {} - const body = req.body || {} - const params = req.params || {} - return query[parameter] || body[parameter] || params[parameter] || null - } - - static requestOptions (req, res) { - let userStore, accountManager, localAuth - if (req.app && req.app.locals) { - const locals = req.app.locals - if (locals.oidc) { - userStore = locals.oidc.users - } - accountManager = locals.accountManager - localAuth = locals.localAuth - } - const authQueryParams = AuthRequest.extractAuthParams(req) - const returnToUrl = AuthRequest.parseParameter(req, 'returnToUrl') - const acceptToc = AuthRequest.parseParameter(req, 'acceptToc') === 'true' - const options = { - response: res, - session: req.session, - userStore, - accountManager, - returnToUrl, - authQueryParams, - localAuth, - acceptToc - } - return options - } - - static extractAuthParams (req) { - let params - if (req.method === 'POST') { - params = req.body - } else { - params = req.query - } - if (!params) { return {} } - const extracted = {} - const paramKeys = AUTH_QUERY_PARAMS - let value - for (const p of paramKeys) { - value = params[p] - extracted[p] = value - } - if (!extracted.redirect_uri && params.request) { - try { - const IDToken = requireCjs('@solid/oidc-op/src/IDToken.js') - if (IDToken && IDToken.decode) { - extracted.redirect_uri = IDToken.decode(params.request).payload.redirect_uri - } - } catch (e) { - // If the package isn't available, skip decoding the request token. - // This preserves behavior for tests/environments without the dependency. - } - } - return extracted - } - - error (error, body) { - error.statusCode = error.statusCode || 400 - this.renderForm(error, body) - } - - initUserSession (userAccount) { - const session = this.session - debug('Initializing user session with webId: ', userAccount.webId) - session.userId = userAccount.webId - session.subject = { _id: userAccount.webId } - return userAccount - } - - authorizeUrl () { - const host = this.accountManager.host - const authUrl = host.authEndpoint - // Build a WHATWG URL and attach query params - let theUrl - if (typeof authUrl === 'string') { - theUrl = new URL(authUrl) - } else if (authUrl && authUrl.pathname) { - theUrl = new URL(authUrl.pathname, this.accountManager.host.serverUri) - } else { - theUrl = new URL(this.accountManager.host.serverUri) - } - attachQueryParams(theUrl, this.authQueryParams) - return theUrl.toString() - } - - registerUrl () { - const host = this.accountManager.host - const signupUrl = new URL('/register', host.serverUri) - attachQueryParams(signupUrl, this.authQueryParams) - return signupUrl.toString() - } - - loginUrl () { - const host = this.accountManager.host - const signupUrl = new URL('/login', host.serverUri) - attachQueryParams(signupUrl, this.authQueryParams) - return signupUrl.toString() - } - - sharingUrl () { - const host = this.accountManager.host - const sharingUrl = new URL('/sharing', host.serverUri) - attachQueryParams(sharingUrl, this.authQueryParams) - return sharingUrl.toString() - } -} -AuthRequest.AUTH_QUERY_PARAMS = AUTH_QUERY_PARAMS +import { URL } from 'url' +import debugModule from '../debug.js' +import { createRequire } from 'module' + +// Helper: attach key/value pairs from `params` into URLSearchParams of `urlObj` +function attachQueryParams (urlObj, params) { + if (!params) return urlObj + for (const [k, v] of Object.entries(params)) { + if (v != null) urlObj.searchParams.set(k, v) + } + return urlObj +} + +// Avoid importing `@solid/oidc-op` at module-evaluation time to prevent +// import errors in environments where that package isn't resolvable. +// We'll try to require it lazily when needed. +const requireCjs = createRequire(import.meta.url) + +const debug = debugModule.authentication + +const AUTH_QUERY_PARAMS = [ + 'response_type', 'display', 'scope', + 'client_id', 'redirect_uri', 'state', 'nonce', 'request' +] + +export default class AuthRequest { + constructor (options) { + this.response = options.response + this.session = options.session || {} + this.userStore = options.userStore + this.accountManager = options.accountManager + this.returnToUrl = options.returnToUrl + this.authQueryParams = options.authQueryParams || {} + this.localAuth = options.localAuth + this.enforceToc = options.enforceToc + this.tocUri = options.tocUri + } + + static parseParameter (req, parameter) { + const query = req.query || {} + const body = req.body || {} + const params = req.params || {} + return query[parameter] || body[parameter] || params[parameter] || null + } + + static requestOptions (req, res) { + let userStore, accountManager, localAuth + if (req.app && req.app.locals) { + const locals = req.app.locals + if (locals.oidc) { + userStore = locals.oidc.users + } + accountManager = locals.accountManager + localAuth = locals.localAuth + } + const authQueryParams = AuthRequest.extractAuthParams(req) + const returnToUrl = AuthRequest.parseParameter(req, 'returnToUrl') + const acceptToc = AuthRequest.parseParameter(req, 'acceptToc') === 'true' + const options = { + response: res, + session: req.session, + userStore, + accountManager, + returnToUrl, + authQueryParams, + localAuth, + acceptToc + } + return options + } + + static extractAuthParams (req) { + let params + if (req.method === 'POST') { + params = req.body + } else { + params = req.query + } + if (!params) { return {} } + const extracted = {} + const paramKeys = AUTH_QUERY_PARAMS + let value + for (const p of paramKeys) { + value = params[p] + extracted[p] = value + } + if (!extracted.redirect_uri && params.request) { + try { + const IDToken = requireCjs('@solid/oidc-op/src/IDToken.js') + if (IDToken && IDToken.decode) { + extracted.redirect_uri = IDToken.decode(params.request).payload.redirect_uri + } + } catch (e) { + // If the package isn't available, skip decoding the request token. + // This preserves behavior for tests/environments without the dependency. + } + } + return extracted + } + + error (error, body) { + error.statusCode = error.statusCode || 400 + this.renderForm(error, body) + } + + initUserSession (userAccount) { + const session = this.session + debug('Initializing user session with webId: ', userAccount.webId) + session.userId = userAccount.webId + session.subject = { _id: userAccount.webId } + return userAccount + } + + authorizeUrl () { + const host = this.accountManager.host + const authUrl = host.authEndpoint + // Build a WHATWG URL and attach query params + let theUrl + if (typeof authUrl === 'string') { + theUrl = new URL(authUrl) + } else if (authUrl && authUrl.pathname) { + theUrl = new URL(authUrl.pathname, this.accountManager.host.serverUri) + } else { + theUrl = new URL(this.accountManager.host.serverUri) + } + attachQueryParams(theUrl, this.authQueryParams) + return theUrl.toString() + } + + registerUrl () { + const host = this.accountManager.host + const signupUrl = new URL('/register', host.serverUri) + attachQueryParams(signupUrl, this.authQueryParams) + return signupUrl.toString() + } + + loginUrl () { + const host = this.accountManager.host + const signupUrl = new URL('/login', host.serverUri) + attachQueryParams(signupUrl, this.authQueryParams) + return signupUrl.toString() + } + + sharingUrl () { + const host = this.accountManager.host + const sharingUrl = new URL('/sharing', host.serverUri) + attachQueryParams(sharingUrl, this.authQueryParams) + return sharingUrl.toString() + } +} +AuthRequest.AUTH_QUERY_PARAMS = AUTH_QUERY_PARAMS diff --git a/lib/requests/create-account-request.mjs b/lib/requests/create-account-request.js similarity index 94% rename from lib/requests/create-account-request.mjs rename to lib/requests/create-account-request.js index 487b259c0..126fdc3b1 100644 --- a/lib/requests/create-account-request.mjs +++ b/lib/requests/create-account-request.js @@ -1,265 +1,265 @@ -import AuthRequest from './auth-request.mjs' -import WebIdTlsCertificate from '../models/webid-tls-certificate.mjs' -import debugModule from '../debug.mjs' -import blacklistService from '../services/blacklist-service.mjs' -import { isValidUsername } from '../common/user-utils.mjs' - -const debug = debugModule.accounts - -export class CreateAccountRequest extends AuthRequest { - constructor (options) { - super(options) - this.username = options.username - this.userAccount = options.userAccount - this.acceptToc = options.acceptToc - this.disablePasswordChecks = options.disablePasswordChecks +import AuthRequest from './auth-request.js' +import WebIdTlsCertificate from '../models/webid-tls-certificate.js' +import debugModule from '../debug.js' +import blacklistService from '../services/blacklist-service.js' +import { isValidUsername } from '../common/user-utils.js' + +const debug = debugModule.accounts + +export class CreateAccountRequest extends AuthRequest { + constructor (options) { + super(options) + this.username = options.username + this.userAccount = options.userAccount + this.acceptToc = options.acceptToc + this.disablePasswordChecks = options.disablePasswordChecks } - - static fromParams (req, res) { - const options = AuthRequest.requestOptions(req, res) - const locals = req.app.locals - const authMethod = locals.authMethod - const accountManager = locals.accountManager - const body = req.body || {} - if (body.username) { - options.username = body.username.toLowerCase() - options.userAccount = accountManager.userAccountFrom(body) - } - options.enforceToc = locals.enforceToc - options.tocUri = locals.tocUri - options.disablePasswordChecks = locals.disablePasswordChecks - switch (authMethod) { - case 'oidc': - options.password = body.password - return new CreateOidcAccountRequest(options) - case 'tls': - options.spkac = body.spkac - return new CreateTlsAccountRequest(options) - default: - throw new TypeError('Unsupported authentication scheme') - } + + static fromParams (req, res) { + const options = AuthRequest.requestOptions(req, res) + const locals = req.app.locals + const authMethod = locals.authMethod + const accountManager = locals.accountManager + const body = req.body || {} + if (body.username) { + options.username = body.username.toLowerCase() + options.userAccount = accountManager.userAccountFrom(body) + } + options.enforceToc = locals.enforceToc + options.tocUri = locals.tocUri + options.disablePasswordChecks = locals.disablePasswordChecks + switch (authMethod) { + case 'oidc': + options.password = body.password + return new CreateOidcAccountRequest(options) + case 'tls': + options.spkac = body.spkac + return new CreateTlsAccountRequest(options) + default: + throw new TypeError('Unsupported authentication scheme') + } } - - static async post (req, res) { - const request = CreateAccountRequest.fromParams(req, res) - try { - request.validate() - await request.createAccount() - } catch (error) { - request.error(error, req.body) - } + + static async post (req, res) { + const request = CreateAccountRequest.fromParams(req, res) + try { + request.validate() + await request.createAccount() + } catch (error) { + request.error(error, req.body) + } } - - static get (req, res) { - const request = CreateAccountRequest.fromParams(req, res) - return Promise.resolve() - .then(() => request.renderForm()) - .catch(error => request.error(error)) + + static get (req, res) { + const request = CreateAccountRequest.fromParams(req, res) + return Promise.resolve() + .then(() => request.renderForm()) + .catch(error => request.error(error)) } - - renderForm (error, data = {}) { - const authMethod = this.accountManager.authMethod - const params = Object.assign({}, this.authQueryParams, { - enforceToc: this.enforceToc, - loginUrl: this.loginUrl(), - multiuser: this.accountManager.multiuser, - registerDisabled: authMethod === 'tls', - returnToUrl: this.returnToUrl, - tocUri: this.tocUri, - disablePasswordChecks: this.disablePasswordChecks, - username: data.username, - name: data.name, - email: data.email, - acceptToc: data.acceptToc - }) - if (error) { - params.error = error.message - this.response.status(error.statusCode) - } - this.response.render('account/register', params) + + renderForm (error, data = {}) { + const authMethod = this.accountManager.authMethod + const params = Object.assign({}, this.authQueryParams, { + enforceToc: this.enforceToc, + loginUrl: this.loginUrl(), + multiuser: this.accountManager.multiuser, + registerDisabled: authMethod === 'tls', + returnToUrl: this.returnToUrl, + tocUri: this.tocUri, + disablePasswordChecks: this.disablePasswordChecks, + username: data.username, + name: data.name, + email: data.email, + acceptToc: data.acceptToc + }) + if (error) { + params.error = error.message + this.response.status(error.statusCode) + } + this.response.render('account/register', params) } - - async createAccount () { - const userAccount = this.userAccount - const accountManager = this.accountManager - if (userAccount.externalWebId) { - const error = new Error('Linked users not currently supported, sorry (external WebID without TLS?)') - error.statusCode = 400 - throw error - } - this.cancelIfUsernameInvalid(userAccount) - this.cancelIfBlacklistedUsername(userAccount) - await this.cancelIfAccountExists(userAccount) - await this.createAccountStorage(userAccount) - await this.saveCredentialsFor(userAccount) - await this.sendResponse(userAccount) - if (userAccount && userAccount.email) { - debug('Sending Welcome email') - accountManager.sendWelcomeEmail(userAccount) - } - return userAccount + + async createAccount () { + const userAccount = this.userAccount + const accountManager = this.accountManager + if (userAccount.externalWebId) { + const error = new Error('Linked users not currently supported, sorry (external WebID without TLS?)') + error.statusCode = 400 + throw error + } + this.cancelIfUsernameInvalid(userAccount) + this.cancelIfBlacklistedUsername(userAccount) + await this.cancelIfAccountExists(userAccount) + await this.createAccountStorage(userAccount) + await this.saveCredentialsFor(userAccount) + await this.sendResponse(userAccount) + if (userAccount && userAccount.email) { + debug('Sending Welcome email') + accountManager.sendWelcomeEmail(userAccount) + } + return userAccount } - - cancelIfAccountExists (userAccount) { - const accountManager = this.accountManager - return accountManager.accountExists(userAccount.username) - .then(exists => { - if (exists) { - debug(`Canceling account creation, ${userAccount.webId} already exists`) - const error = new Error('Account creation failed') - error.status = 400 - throw error - } - return userAccount - }) + + cancelIfAccountExists (userAccount) { + const accountManager = this.accountManager + return accountManager.accountExists(userAccount.username) + .then(exists => { + if (exists) { + debug(`Canceling account creation, ${userAccount.webId} already exists`) + const error = new Error('Account creation failed') + error.status = 400 + throw error + } + return userAccount + }) } - - createAccountStorage (userAccount) { - return this.accountManager.createAccountFor(userAccount) - .catch(error => { - error.message = 'Error creating account storage: ' + error.message - throw error - }) - .then(() => { - debug('Account storage resources created') - return userAccount - }) + + createAccountStorage (userAccount) { + return this.accountManager.createAccountFor(userAccount) + .catch(error => { + error.message = 'Error creating account storage: ' + error.message + throw error + }) + .then(() => { + debug('Account storage resources created') + return userAccount + }) } - - cancelIfUsernameInvalid (userAccount) { - if (!userAccount.username || !isValidUsername(userAccount.username)) { - debug('Invalid username ' + userAccount.username) - const error = new Error('Invalid username (contains invalid characters)') - error.status = 400 - throw error - } - return userAccount + + cancelIfUsernameInvalid (userAccount) { + if (!userAccount.username || !isValidUsername(userAccount.username)) { + debug('Invalid username ' + userAccount.username) + const error = new Error('Invalid username (contains invalid characters)') + error.status = 400 + throw error + } + return userAccount } - - cancelIfBlacklistedUsername (userAccount) { - const validUsername = blacklistService.validate(userAccount.username) - if (!validUsername) { - debug('Invalid username ' + userAccount.username) - const error = new Error('Invalid username (username is blacklisted)') - error.status = 400 - throw error - } - return userAccount - } -} - -export class CreateOidcAccountRequest extends CreateAccountRequest { - constructor (options) { - super(options) - this.password = options.password + + cancelIfBlacklistedUsername (userAccount) { + const validUsername = blacklistService.validate(userAccount.username) + if (!validUsername) { + debug('Invalid username ' + userAccount.username) + const error = new Error('Invalid username (username is blacklisted)') + error.status = 400 + throw error + } + return userAccount } - - validate () { - let error - if (!this.username) { - error = new Error('Username required') - error.statusCode = 400 - throw error - } - if (!this.password) { - error = new Error('Password required') - error.statusCode = 400 - throw error - } - if (this.enforceToc && !this.acceptToc) { - error = new Error('Accepting Terms & Conditions is required for this service') - error.statusCode = 400 - throw error - } +} + +export class CreateOidcAccountRequest extends CreateAccountRequest { + constructor (options) { + super(options) + this.password = options.password } - - saveCredentialsFor (userAccount) { - return this.userStore.createUser(userAccount, this.password) - .then(() => { - debug('User credentials stored') - return userAccount - }) + + validate () { + let error + if (!this.username) { + error = new Error('Username required') + error.statusCode = 400 + throw error + } + if (!this.password) { + error = new Error('Password required') + error.statusCode = 400 + throw error + } + if (this.enforceToc && !this.acceptToc) { + error = new Error('Accepting Terms & Conditions is required for this service') + error.statusCode = 400 + throw error + } } - - sendResponse (userAccount) { - const redirectUrl = this.returnToUrl || userAccount.podUri - this.response.redirect(redirectUrl) - return userAccount - } -} - -export class CreateTlsAccountRequest extends CreateAccountRequest { - constructor (options) { - super(options) - this.spkac = options.spkac - this.certificate = null + + saveCredentialsFor (userAccount) { + return this.userStore.createUser(userAccount, this.password) + .then(() => { + debug('User credentials stored') + return userAccount + }) } - - validate () { - let error - if (!this.username) { - error = new Error('Username required') - error.statusCode = 400 - throw error - } - if (this.enforceToc && !this.acceptToc) { - error = new Error('Accepting Terms & Conditions is required for this service') - error.statusCode = 400 - throw error - } + + sendResponse (userAccount) { + const redirectUrl = this.returnToUrl || userAccount.podUri + this.response.redirect(redirectUrl) + return userAccount } - - generateTlsCertificate (userAccount) { - if (!this.spkac) { - debug('Missing spkac param, not generating cert during account creation') - return Promise.resolve(userAccount) - } - return Promise.resolve() - .then(() => { - const host = this.accountManager.host - return WebIdTlsCertificate.fromSpkacPost(this.spkac, userAccount, host) - .generateCertificate() - }) - .catch(err => { - err.status = 400 - err.message = 'Error generating a certificate: ' + err.message - throw err - }) - .then(certificate => { - debug('Generated a WebID-TLS certificate as part of account creation') - this.certificate = certificate - return userAccount - }) +} + +export class CreateTlsAccountRequest extends CreateAccountRequest { + constructor (options) { + super(options) + this.spkac = options.spkac + this.certificate = null } - - saveCredentialsFor (userAccount) { - return this.generateTlsCertificate(userAccount) - .then(userAccount => { - if (this.certificate) { - return this.accountManager - .addCertKeyToProfile(this.certificate, userAccount) - .then(() => { - debug('Saved generated WebID-TLS certificate to profile') - }) - } else { - debug('No certificate generated, no need to save to profile') - } - }) - .then(() => { - return userAccount - }) + + validate () { + let error + if (!this.username) { + error = new Error('Username required') + error.statusCode = 400 + throw error + } + if (this.enforceToc && !this.acceptToc) { + error = new Error('Accepting Terms & Conditions is required for this service') + error.statusCode = 400 + throw error + } } - - sendResponse (userAccount) { - const res = this.response - res.set('User', userAccount.webId) - res.status(200) - if (this.certificate) { - res.set('Content-Type', 'application/x-x509-user-cert') - res.send(this.certificate.toDER()) - } else { - res.end() - } - return userAccount - } -} + + generateTlsCertificate (userAccount) { + if (!this.spkac) { + debug('Missing spkac param, not generating cert during account creation') + return Promise.resolve(userAccount) + } + return Promise.resolve() + .then(() => { + const host = this.accountManager.host + return WebIdTlsCertificate.fromSpkacPost(this.spkac, userAccount, host) + .generateCertificate() + }) + .catch(err => { + err.status = 400 + err.message = 'Error generating a certificate: ' + err.message + throw err + }) + .then(certificate => { + debug('Generated a WebID-TLS certificate as part of account creation') + this.certificate = certificate + return userAccount + }) + } + + saveCredentialsFor (userAccount) { + return this.generateTlsCertificate(userAccount) + .then(userAccount => { + if (this.certificate) { + return this.accountManager + .addCertKeyToProfile(this.certificate, userAccount) + .then(() => { + debug('Saved generated WebID-TLS certificate to profile') + }) + } else { + debug('No certificate generated, no need to save to profile') + } + }) + .then(() => { + return userAccount + }) + } + + sendResponse (userAccount) { + const res = this.response + res.set('User', userAccount.webId) + res.status(200) + if (this.certificate) { + res.set('Content-Type', 'application/x-x509-user-cert') + res.send(this.certificate.toDER()) + } else { + res.end() + } + return userAccount + } +} diff --git a/lib/requests/delete-account-confirm-request.mjs b/lib/requests/delete-account-confirm-request.js similarity index 93% rename from lib/requests/delete-account-confirm-request.mjs rename to lib/requests/delete-account-confirm-request.js index 5dbd69acc..3eaedc508 100644 --- a/lib/requests/delete-account-confirm-request.mjs +++ b/lib/requests/delete-account-confirm-request.js @@ -1,85 +1,85 @@ -import AuthRequest from './auth-request.mjs' -import debugModule from '../debug.mjs' -import fs from 'fs-extra' - -const debug = debugModule.accounts - -export default class DeleteAccountConfirmRequest extends AuthRequest { - constructor (options) { - super(options) - this.token = options.token - this.validToken = false +import AuthRequest from './auth-request.js' +import debugModule from '../debug.js' +import fs from 'fs-extra' + +const debug = debugModule.accounts + +export default class DeleteAccountConfirmRequest extends AuthRequest { + constructor (options) { + super(options) + this.token = options.token + this.validToken = false } - - static fromParams (req, res) { - const locals = req.app.locals - const accountManager = locals.accountManager - const userStore = locals.oidc.users - const token = this.parseParameter(req, 'token') - const options = { accountManager, userStore, token, response: res } - return new DeleteAccountConfirmRequest(options) + + static fromParams (req, res) { + const locals = req.app.locals + const accountManager = locals.accountManager + const userStore = locals.oidc.users + const token = this.parseParameter(req, 'token') + const options = { accountManager, userStore, token, response: res } + return new DeleteAccountConfirmRequest(options) } - - static async get (req, res) { - const request = DeleteAccountConfirmRequest.fromParams(req, res) - try { - await request.validateToken() - return request.renderForm() - } catch (error) { - return request.error(error) - } + + static async get (req, res) { + const request = DeleteAccountConfirmRequest.fromParams(req, res) + try { + await request.validateToken() + return request.renderForm() + } catch (error) { + return request.error(error) + } } - - static post (req, res) { - const request = DeleteAccountConfirmRequest.fromParams(req, res) - return DeleteAccountConfirmRequest.handlePost(request) + + static post (req, res) { + const request = DeleteAccountConfirmRequest.fromParams(req, res) + return DeleteAccountConfirmRequest.handlePost(request) } - - static async handlePost (request) { - try { - const tokenContents = await request.validateToken() - await request.deleteAccount(tokenContents) - return request.renderSuccess() - } catch (error) { - return request.error(error) - } + + static async handlePost (request) { + try { + const tokenContents = await request.validateToken() + await request.deleteAccount(tokenContents) + return request.renderSuccess() + } catch (error) { + return request.error(error) + } } - - async validateToken () { - try { - if (!this.token) { - return false - } - const validToken = await this.accountManager.validateDeleteToken(this.token) - if (validToken) { - this.validToken = true - } - return validToken - } catch (error) { - this.token = null - throw error - } + + async validateToken () { + try { + if (!this.token) { + return false + } + const validToken = await this.accountManager.validateDeleteToken(this.token) + if (validToken) { + this.validToken = true + } + return validToken + } catch (error) { + this.token = null + throw error + } } - - async deleteAccount (tokenContents) { - const user = this.accountManager.userAccountFrom(tokenContents) - const accountDir = this.accountManager.accountDirFor(user.username) - debug('Preparing removal of account for user:', user) - await this.userStore.deleteUser(user) - await fs.remove(accountDir) - debug('Removed user' + user.username) + + async deleteAccount (tokenContents) { + const user = this.accountManager.userAccountFrom(tokenContents) + const accountDir = this.accountManager.accountDirFor(user.username) + debug('Preparing removal of account for user:', user) + await this.userStore.deleteUser(user) + await fs.remove(accountDir) + debug('Removed user' + user.username) } - - renderForm (error) { - const params = { validToken: this.validToken, token: this.token } - if (error) { - params.error = error.message - this.response.status(error.statusCode) - } - this.response.render('account/delete-confirm', params) + + renderForm (error) { + const params = { validToken: this.validToken, token: this.token } + if (error) { + params.error = error.message + this.response.status(error.statusCode) + } + this.response.render('account/delete-confirm', params) } - - renderSuccess () { - this.response.render('account/account-deleted') - } -} + + renderSuccess () { + this.response.render('account/account-deleted') + } +} diff --git a/lib/requests/delete-account-request.mjs b/lib/requests/delete-account-request.js similarity index 93% rename from lib/requests/delete-account-request.mjs rename to lib/requests/delete-account-request.js index aba515264..df74deb0a 100644 --- a/lib/requests/delete-account-request.mjs +++ b/lib/requests/delete-account-request.js @@ -1,83 +1,83 @@ -import AuthRequest from './auth-request.mjs' -import debugModule from '../debug.mjs' - -const debug = debugModule.accounts - -export default class DeleteAccountRequest extends AuthRequest { - constructor (options) { - super(options) - this.username = options.username +import AuthRequest from './auth-request.js' +import debugModule from '../debug.js' + +const debug = debugModule.accounts + +export default class DeleteAccountRequest extends AuthRequest { + constructor (options) { + super(options) + this.username = options.username } - - error (error) { - error.statusCode = error.statusCode || 400 - this.renderForm(error) + + error (error) { + error.statusCode = error.statusCode || 400 + this.renderForm(error) } - - async loadUser () { - const username = this.username - return this.accountManager.accountExists(username) - .then(exists => { - if (!exists) { - throw new Error('Account not found for that username') - } - const userData = { username } - return this.accountManager.userAccountFrom(userData) - }) + + async loadUser () { + const username = this.username + return this.accountManager.accountExists(username) + .then(exists => { + if (!exists) { + throw new Error('Account not found for that username') + } + const userData = { username } + return this.accountManager.userAccountFrom(userData) + }) } - - renderForm (error) { - this.response.render('account/delete', { - error, - multiuser: this.accountManager.multiuser - }) + + renderForm (error) { + this.response.render('account/delete', { + error, + multiuser: this.accountManager.multiuser + }) } - - renderSuccess () { - this.response.render('account/delete-link-sent') + + renderSuccess () { + this.response.render('account/delete-link-sent') } - - async sendDeleteLink (userAccount) { - const accountManager = this.accountManager - const recoveryEmail = await accountManager.loadAccountRecoveryEmail(userAccount) - userAccount.email = recoveryEmail - debug('Preparing delete account email to:', recoveryEmail) - return accountManager.sendDeleteAccountEmail(userAccount) + + async sendDeleteLink (userAccount) { + const accountManager = this.accountManager + const recoveryEmail = await accountManager.loadAccountRecoveryEmail(userAccount) + userAccount.email = recoveryEmail + debug('Preparing delete account email to:', recoveryEmail) + return accountManager.sendDeleteAccountEmail(userAccount) } - - validate () { - if (this.accountManager.multiuser && !this.username) { - throw new Error('Username required') - } + + validate () { + if (this.accountManager.multiuser && !this.username) { + throw new Error('Username required') + } } - - static async post (req, res) { - const request = DeleteAccountRequest.fromParams(req, res) - debug(`User '${request.username}' requested to be sent a delete account email`) - return DeleteAccountRequest.handlePost(request) + + static async post (req, res) { + const request = DeleteAccountRequest.fromParams(req, res) + debug(`User '${request.username}' requested to be sent a delete account email`) + return DeleteAccountRequest.handlePost(request) } - - static async handlePost (request) { - try { - request.validate() - const userAccount = await request.loadUser() - await request.sendDeleteLink(userAccount) - return request.renderSuccess() - } catch (error) { - return request.error(error) - } + + static async handlePost (request) { + try { + request.validate() + const userAccount = await request.loadUser() + await request.sendDeleteLink(userAccount) + return request.renderSuccess() + } catch (error) { + return request.error(error) + } } - - static get (req, res) { - const request = DeleteAccountRequest.fromParams(req, res) - request.renderForm() + + static get (req, res) { + const request = DeleteAccountRequest.fromParams(req, res) + request.renderForm() } - - static fromParams (req, res) { - const locals = req.app.locals - const accountManager = locals.accountManager - const username = this.parseParameter(req, 'username') - const options = { accountManager, response: res, username } - return new DeleteAccountRequest(options) - } -} + + static fromParams (req, res) { + const locals = req.app.locals + const accountManager = locals.accountManager + const username = this.parseParameter(req, 'username') + const options = { accountManager, response: res, username } + return new DeleteAccountRequest(options) + } +} diff --git a/lib/requests/login-request.mjs b/lib/requests/login-request.js similarity index 92% rename from lib/requests/login-request.mjs rename to lib/requests/login-request.js index a32942d19..9e8c83180 100644 --- a/lib/requests/login-request.mjs +++ b/lib/requests/login-request.js @@ -1,89 +1,89 @@ -import debugModule from '../debug.mjs' -import AuthRequest from './auth-request.mjs' -import { PasswordAuthenticator, TlsAuthenticator } from '../models/authenticator.mjs' - -const debug = debugModule.authentication - -export const PASSWORD_AUTH = 'password' -export const TLS_AUTH = 'tls' - -export class LoginRequest extends AuthRequest { - constructor (options) { - super(options) - this.authenticator = options.authenticator - this.authMethod = options.authMethod - } - - static fromParams (req, res, authMethod) { - const options = AuthRequest.requestOptions(req, res) - options.authMethod = authMethod - switch (authMethod) { - case PASSWORD_AUTH: - options.authenticator = PasswordAuthenticator.fromParams(req, options) - break - case TLS_AUTH: - options.authenticator = TlsAuthenticator.fromParams(req, options) - break - default: - options.authenticator = null - break - } - return new LoginRequest(options) - } - - static get (req, res) { - const request = LoginRequest.fromParams(req, res) - request.renderForm(null, req) - } - - static loginPassword (req, res) { - debug('Logging in via username + password') - const request = LoginRequest.fromParams(req, res, PASSWORD_AUTH) - return LoginRequest.login(request) - } - - static loginTls (req, res) { - debug('Logging in via WebID-TLS certificate') - const request = LoginRequest.fromParams(req, res, TLS_AUTH) - return LoginRequest.login(request) - } - - static login (request) { - return request.authenticator.findValidUser() - .then(validUser => { - request.initUserSession(validUser) - request.redirectPostLogin(validUser) - }) - .catch(error => request.error(error)) - } - - postLoginUrl (validUser) { - if (/token|code/.test(this.authQueryParams.response_type)) { - return this.sharingUrl() - } else if (validUser) { - return this.authQueryParams.redirect_uri || validUser.accountUri - } - } - - redirectPostLogin (validUser) { - const uri = this.postLoginUrl(validUser) - debug('Login successful, redirecting to ', uri) - this.response.redirect(uri) - } - - renderForm (error, req) { - const queryString = (req && req.url && req.url.replace(/[^?]+\?/, '')) || '' - const params = Object.assign({}, this.authQueryParams, { - registerUrl: this.registerUrl(), - returnToUrl: this.returnToUrl, - enablePassword: this.localAuth.password, - enableTls: this.localAuth.tls, - tlsUrl: `/login/tls?${encodeURIComponent(queryString)}` - }) - if (error) { - params.error = error.message - this.response.status(error.statusCode) - } - this.response.render('auth/login', params) - } -} +import debugModule from '../debug.js' +import AuthRequest from './auth-request.js' +import { PasswordAuthenticator, TlsAuthenticator } from '../models/authenticator.js' + +const debug = debugModule.authentication + +export const PASSWORD_AUTH = 'password' +export const TLS_AUTH = 'tls' + +export class LoginRequest extends AuthRequest { + constructor (options) { + super(options) + this.authenticator = options.authenticator + this.authMethod = options.authMethod + } + + static fromParams (req, res, authMethod) { + const options = AuthRequest.requestOptions(req, res) + options.authMethod = authMethod + switch (authMethod) { + case PASSWORD_AUTH: + options.authenticator = PasswordAuthenticator.fromParams(req, options) + break + case TLS_AUTH: + options.authenticator = TlsAuthenticator.fromParams(req, options) + break + default: + options.authenticator = null + break + } + return new LoginRequest(options) + } + + static get (req, res) { + const request = LoginRequest.fromParams(req, res) + request.renderForm(null, req) + } + + static loginPassword (req, res) { + debug('Logging in via username + password') + const request = LoginRequest.fromParams(req, res, PASSWORD_AUTH) + return LoginRequest.login(request) + } + + static loginTls (req, res) { + debug('Logging in via WebID-TLS certificate') + const request = LoginRequest.fromParams(req, res, TLS_AUTH) + return LoginRequest.login(request) + } + + static login (request) { + return request.authenticator.findValidUser() + .then(validUser => { + request.initUserSession(validUser) + request.redirectPostLogin(validUser) + }) + .catch(error => request.error(error)) + } + + postLoginUrl (validUser) { + if (/token|code/.test(this.authQueryParams.response_type)) { + return this.sharingUrl() + } else if (validUser) { + return this.authQueryParams.redirect_uri || validUser.accountUri + } + } + + redirectPostLogin (validUser) { + const uri = this.postLoginUrl(validUser) + debug('Login successful, redirecting to ', uri) + this.response.redirect(uri) + } + + renderForm (error, req) { + const queryString = (req && req.url && req.url.replace(/[^?]+\?/, '')) || '' + const params = Object.assign({}, this.authQueryParams, { + registerUrl: this.registerUrl(), + returnToUrl: this.returnToUrl, + enablePassword: this.localAuth.password, + enableTls: this.localAuth.tls, + tlsUrl: `/login/tls?${encodeURIComponent(queryString)}` + }) + if (error) { + params.error = error.message + this.response.status(error.statusCode) + } + this.response.render('auth/login', params) + } +} diff --git a/lib/requests/password-change-request.mjs b/lib/requests/password-change-request.js similarity index 93% rename from lib/requests/password-change-request.mjs rename to lib/requests/password-change-request.js index 3dc896a2e..08e5a3844 100644 --- a/lib/requests/password-change-request.mjs +++ b/lib/requests/password-change-request.js @@ -1,132 +1,132 @@ -import debugModule from '../debug.mjs' -import AuthRequest from './auth-request.mjs' - -const debug = debugModule.accounts - -export default class PasswordChangeRequest extends AuthRequest { - constructor (options) { - super(options) - - this.token = options.token - this.returnToUrl = options.returnToUrl - - this.validToken = false - - this.newPassword = options.newPassword - this.userStore = options.userStore - this.response = options.response - } - - static fromParams (req, res) { - const locals = req.app && req.app.locals ? req.app.locals : {} - const accountManager = locals.accountManager - const userStore = locals.oidc ? locals.oidc.users : undefined - - const returnToUrl = this.parseParameter(req, 'returnToUrl') - const token = this.parseParameter(req, 'token') - const oldPassword = this.parseParameter(req, 'password') - const newPassword = this.parseParameter(req, 'newPassword') - - const options = { - accountManager, - userStore, - returnToUrl, - token, - oldPassword, - newPassword, - response: res - } - - return new PasswordChangeRequest(options) - } - - static get (req, res) { - const request = PasswordChangeRequest.fromParams(req, res) - - return Promise.resolve() - .then(() => request.validateToken()) - .then(() => request.renderForm()) - .catch(error => request.error(error)) - } - - static post (req, res) { - const request = PasswordChangeRequest.fromParams(req, res) - - return PasswordChangeRequest.handlePost(request) - } - - static handlePost (request) { - return Promise.resolve() - .then(() => request.validatePost()) - .then(() => request.validateToken()) - .then(tokenContents => request.changePassword(tokenContents)) - .then(() => request.renderSuccess()) - .catch(error => request.error(error)) - } - - validatePost () { - if (!this.newPassword) { - throw new Error('Please enter a new password') - } - } - - validateToken () { - return Promise.resolve() - .then(() => { - if (!this.token) { return false } - - return this.accountManager.validateResetToken(this.token) - }) - .then(validToken => { - if (validToken) { - this.validToken = true - } - - return validToken - }) - .catch(error => { - this.token = null - throw error - }) - } - - changePassword (tokenContents) { - const user = this.accountManager.userAccountFrom(tokenContents) - - debug('Changing password for user:', user.webId) - - return this.userStore.findUser(user.id) - .then(userStoreEntry => { - if (userStoreEntry) { - return this.userStore.updatePassword(user, this.newPassword) - } else { - return this.userStore.createUser(user, this.newPassword) - } - }) - } - - renderForm (error) { - const params = { - validToken: this.validToken, - returnToUrl: this.returnToUrl, - token: this.token - } - - if (error) { - params.error = error.message - this.response.status(error.statusCode) - } - - this.response.render('auth/change-password', params) - } - - renderSuccess () { - this.response.render('auth/password-changed', { returnToUrl: this.returnToUrl }) - } - - error (error) { - error.statusCode = error.statusCode || 400 - - this.renderForm(error) - } -} +import debugModule from '../debug.js' +import AuthRequest from './auth-request.js' + +const debug = debugModule.accounts + +export default class PasswordChangeRequest extends AuthRequest { + constructor (options) { + super(options) + + this.token = options.token + this.returnToUrl = options.returnToUrl + + this.validToken = false + + this.newPassword = options.newPassword + this.userStore = options.userStore + this.response = options.response + } + + static fromParams (req, res) { + const locals = req.app && req.app.locals ? req.app.locals : {} + const accountManager = locals.accountManager + const userStore = locals.oidc ? locals.oidc.users : undefined + + const returnToUrl = this.parseParameter(req, 'returnToUrl') + const token = this.parseParameter(req, 'token') + const oldPassword = this.parseParameter(req, 'password') + const newPassword = this.parseParameter(req, 'newPassword') + + const options = { + accountManager, + userStore, + returnToUrl, + token, + oldPassword, + newPassword, + response: res + } + + return new PasswordChangeRequest(options) + } + + static get (req, res) { + const request = PasswordChangeRequest.fromParams(req, res) + + return Promise.resolve() + .then(() => request.validateToken()) + .then(() => request.renderForm()) + .catch(error => request.error(error)) + } + + static post (req, res) { + const request = PasswordChangeRequest.fromParams(req, res) + + return PasswordChangeRequest.handlePost(request) + } + + static handlePost (request) { + return Promise.resolve() + .then(() => request.validatePost()) + .then(() => request.validateToken()) + .then(tokenContents => request.changePassword(tokenContents)) + .then(() => request.renderSuccess()) + .catch(error => request.error(error)) + } + + validatePost () { + if (!this.newPassword) { + throw new Error('Please enter a new password') + } + } + + validateToken () { + return Promise.resolve() + .then(() => { + if (!this.token) { return false } + + return this.accountManager.validateResetToken(this.token) + }) + .then(validToken => { + if (validToken) { + this.validToken = true + } + + return validToken + }) + .catch(error => { + this.token = null + throw error + }) + } + + changePassword (tokenContents) { + const user = this.accountManager.userAccountFrom(tokenContents) + + debug('Changing password for user:', user.webId) + + return this.userStore.findUser(user.id) + .then(userStoreEntry => { + if (userStoreEntry) { + return this.userStore.updatePassword(user, this.newPassword) + } else { + return this.userStore.createUser(user, this.newPassword) + } + }) + } + + renderForm (error) { + const params = { + validToken: this.validToken, + returnToUrl: this.returnToUrl, + token: this.token + } + + if (error) { + params.error = error.message + this.response.status(error.statusCode) + } + + this.response.render('auth/change-password', params) + } + + renderSuccess () { + this.response.render('auth/password-changed', { returnToUrl: this.returnToUrl }) + } + + error (error) { + error.statusCode = error.statusCode || 400 + + this.renderForm(error) + } +} diff --git a/lib/requests/password-reset-email-request.mjs b/lib/requests/password-reset-email-request.js similarity index 93% rename from lib/requests/password-reset-email-request.mjs rename to lib/requests/password-reset-email-request.js index 11c14e74a..c77209be1 100644 --- a/lib/requests/password-reset-email-request.mjs +++ b/lib/requests/password-reset-email-request.js @@ -1,123 +1,123 @@ -import AuthRequest from './auth-request.mjs' -import debugModule from './../debug.mjs' - -const debug = debugModule.accounts - -export default class PasswordResetEmailRequest extends AuthRequest { - constructor (options) { - super(options) - - this.accountManager = options.accountManager - this.userStore = options.userStore - this.returnToUrl = options.returnToUrl - this.username = options.username - this.response = options.response - } - - static fromParams (req, res) { - const locals = req.app.locals - const accountManager = locals.accountManager - - const returnToUrl = this.parseParameter(req, 'returnToUrl') - const username = this.parseParameter(req, 'username') - - const options = { - accountManager, - returnToUrl, - username, - response: res - } - - return new PasswordResetEmailRequest(options) - } - - static get (req, res) { - const request = PasswordResetEmailRequest.fromParams(req, res) - - request.renderForm() - } - - static post (req, res) { - const request = PasswordResetEmailRequest.fromParams(req, res) - - debug(`User '${request.username}' requested to be sent a password reset email`) - - return PasswordResetEmailRequest.handlePost(request) - } - - static handlePost (request) { - return Promise.resolve() - .then(() => request.validate()) - .then(() => request.loadUser()) - .then(userAccount => request.sendResetLink(userAccount)) - .then(() => request.resetLinkMessage()) - .catch(error => request.error(error)) - } - - validate () { - if (this.accountManager.multiuser && !this.username) { - throw new Error('Username required') - } - } - - loadUser () { - const username = this.username - - return this.accountManager.accountExists(username) - .then(exists => { - if (!exists) { - // For security reasons, avoid leaking error information - // See: https://github.com/nodeSolidServer/node-solid-server/issues/1770 - this.accountManager.verifyEmailDependencies() - return this.resetLinkMessage() - } - - const userData = { username } - - return this.accountManager.userAccountFrom(userData) - }) - } - - sendResetLink (userAccount) { - const accountManager = this.accountManager - - return accountManager.loadAccountRecoveryEmail(userAccount) - .then(recoveryEmail => { - userAccount.email = recoveryEmail - - debug('Sending recovery email to:', recoveryEmail) - - return accountManager - .sendPasswordResetEmail(userAccount, this.returnToUrl) - }) - } - - error (error) { - const res = this.response - - debug(error) - - const params = { - error: error.message, - returnToUrl: this.returnToUrl, - multiuser: this.accountManager.multiuser - } - - res.status(error.statusCode || 400) - - res.render('auth/reset-password', params) - } - - renderForm () { - const params = { - returnToUrl: this.returnToUrl, - multiuser: this.accountManager.multiuser - } - - this.response.render('auth/reset-password', params) - } - - resetLinkMessage () { - this.response.render('auth/reset-link-sent') - } -} +import AuthRequest from './auth-request.js' +import debugModule from './../debug.js' + +const debug = debugModule.accounts + +export default class PasswordResetEmailRequest extends AuthRequest { + constructor (options) { + super(options) + + this.accountManager = options.accountManager + this.userStore = options.userStore + this.returnToUrl = options.returnToUrl + this.username = options.username + this.response = options.response + } + + static fromParams (req, res) { + const locals = req.app.locals + const accountManager = locals.accountManager + + const returnToUrl = this.parseParameter(req, 'returnToUrl') + const username = this.parseParameter(req, 'username') + + const options = { + accountManager, + returnToUrl, + username, + response: res + } + + return new PasswordResetEmailRequest(options) + } + + static get (req, res) { + const request = PasswordResetEmailRequest.fromParams(req, res) + + request.renderForm() + } + + static post (req, res) { + const request = PasswordResetEmailRequest.fromParams(req, res) + + debug(`User '${request.username}' requested to be sent a password reset email`) + + return PasswordResetEmailRequest.handlePost(request) + } + + static handlePost (request) { + return Promise.resolve() + .then(() => request.validate()) + .then(() => request.loadUser()) + .then(userAccount => request.sendResetLink(userAccount)) + .then(() => request.resetLinkMessage()) + .catch(error => request.error(error)) + } + + validate () { + if (this.accountManager.multiuser && !this.username) { + throw new Error('Username required') + } + } + + loadUser () { + const username = this.username + + return this.accountManager.accountExists(username) + .then(exists => { + if (!exists) { + // For security reasons, avoid leaking error information + // See: https://github.com/nodeSolidServer/node-solid-server/issues/1770 + this.accountManager.verifyEmailDependencies() + return this.resetLinkMessage() + } + + const userData = { username } + + return this.accountManager.userAccountFrom(userData) + }) + } + + sendResetLink (userAccount) { + const accountManager = this.accountManager + + return accountManager.loadAccountRecoveryEmail(userAccount) + .then(recoveryEmail => { + userAccount.email = recoveryEmail + + debug('Sending recovery email to:', recoveryEmail) + + return accountManager + .sendPasswordResetEmail(userAccount, this.returnToUrl) + }) + } + + error (error) { + const res = this.response + + debug(error) + + const params = { + error: error.message, + returnToUrl: this.returnToUrl, + multiuser: this.accountManager.multiuser + } + + res.status(error.statusCode || 400) + + res.render('auth/reset-password', params) + } + + renderForm () { + const params = { + returnToUrl: this.returnToUrl, + multiuser: this.accountManager.multiuser + } + + this.response.render('auth/reset-password', params) + } + + resetLinkMessage () { + this.response.render('auth/reset-link-sent') + } +} diff --git a/lib/requests/password-reset-request.mjs b/lib/requests/password-reset-request.js similarity index 96% rename from lib/requests/password-reset-request.mjs rename to lib/requests/password-reset-request.js index 3b2b17da1..5824f62f9 100644 --- a/lib/requests/password-reset-request.mjs +++ b/lib/requests/password-reset-request.js @@ -1,47 +1,47 @@ -export default class PasswordResetRequest { - constructor (options) { - this.accountManager = options.accountManager - this.email = options.email - this.response = options.response - } - - static handle (req, res, accountManager) { - let request - try { - request = PasswordResetRequest.fromParams(req, res, accountManager) - } catch (error) { - return Promise.reject(error) - } - return PasswordResetRequest.resetPassword(request) - } - - static fromParams (req, res, accountManager) { - const email = req.body.email - if (!email) { - const error = new Error('Email is required for password reset') - error.status = 400 - throw error - } - const options = { accountManager, email, response: res } - return new PasswordResetRequest(options) - } - - static resetPassword (request) { - const { accountManager, email } = request - return accountManager.resetPassword(email) - .catch(err => { - err.status = 400 - err.message = 'Error resetting password: ' + err.message - throw err - }) - .then(() => { - request.sendResponse() - }) - } - - sendResponse () { - const { response } = this - response.status(200) - response.send({ message: 'Password reset email sent' }) - } -} +export default class PasswordResetRequest { + constructor (options) { + this.accountManager = options.accountManager + this.email = options.email + this.response = options.response + } + + static handle (req, res, accountManager) { + let request + try { + request = PasswordResetRequest.fromParams(req, res, accountManager) + } catch (error) { + return Promise.reject(error) + } + return PasswordResetRequest.resetPassword(request) + } + + static fromParams (req, res, accountManager) { + const email = req.body.email + if (!email) { + const error = new Error('Email is required for password reset') + error.status = 400 + throw error + } + const options = { accountManager, email, response: res } + return new PasswordResetRequest(options) + } + + static resetPassword (request) { + const { accountManager, email } = request + return accountManager.resetPassword(email) + .catch(err => { + err.status = 400 + err.message = 'Error resetting password: ' + err.message + throw err + }) + .then(() => { + request.sendResponse() + }) + } + + sendResponse () { + const { response } = this + response.status(200) + response.send({ message: 'Password reset email sent' }) + } +} diff --git a/lib/requests/register-request.mjs b/lib/requests/register-request.js similarity index 96% rename from lib/requests/register-request.mjs rename to lib/requests/register-request.js index bdb5430ae..801ec81a5 100644 --- a/lib/requests/register-request.mjs +++ b/lib/requests/register-request.js @@ -1,48 +1,48 @@ -export default class RegisterRequest { - constructor (options) { - this.accountManager = options.accountManager - this.userAccount = options.userAccount - this.response = options.response - } - - static handle (req, res, accountManager) { - let request - try { - request = RegisterRequest.fromParams(req, res, accountManager) - } catch (error) { - return Promise.reject(error) - } - return RegisterRequest.register(request) - } - - static fromParams (req, res, accountManager) { - const userAccount = accountManager.userAccountFrom(req.body) - if (!userAccount) { - const error = new Error('User account information is required') - error.status = 400 - throw error - } - const options = { accountManager, userAccount, response: res } - return new RegisterRequest(options) - } - - static register (request) { - const { accountManager, userAccount } = request - return accountManager.register(userAccount) - .catch(err => { - err.status = 400 - err.message = 'Error registering user: ' + err.message - throw err - }) - .then(() => { - request.sendResponse() - }) - } - - sendResponse () { - const { response, userAccount } = this - response.set('User', userAccount.webId) - response.status(201) - response.send({ message: 'User registered successfully' }) - } -} +export default class RegisterRequest { + constructor (options) { + this.accountManager = options.accountManager + this.userAccount = options.userAccount + this.response = options.response + } + + static handle (req, res, accountManager) { + let request + try { + request = RegisterRequest.fromParams(req, res, accountManager) + } catch (error) { + return Promise.reject(error) + } + return RegisterRequest.register(request) + } + + static fromParams (req, res, accountManager) { + const userAccount = accountManager.userAccountFrom(req.body) + if (!userAccount) { + const error = new Error('User account information is required') + error.status = 400 + throw error + } + const options = { accountManager, userAccount, response: res } + return new RegisterRequest(options) + } + + static register (request) { + const { accountManager, userAccount } = request + return accountManager.register(userAccount) + .catch(err => { + err.status = 400 + err.message = 'Error registering user: ' + err.message + throw err + }) + .then(() => { + request.sendResponse() + }) + } + + sendResponse () { + const { response, userAccount } = this + response.set('User', userAccount.webId) + response.status(201) + response.send({ message: 'User registered successfully' }) + } +} diff --git a/lib/requests/sharing-request.mjs b/lib/requests/sharing-request.js similarity index 95% rename from lib/requests/sharing-request.mjs rename to lib/requests/sharing-request.js index ae6fc0c29..44299f3ef 100644 --- a/lib/requests/sharing-request.mjs +++ b/lib/requests/sharing-request.js @@ -1,174 +1,174 @@ -import debugModule from '../debug.mjs' -import AuthRequest from './auth-request.mjs' -import url from 'url' -import intoStream from 'into-stream' -import * as $rdf from 'rdflib' - -const debug = debugModule.authentication -const ACL = $rdf.Namespace('http://www.w3.org/ns/auth/acl#') - -export class SharingRequest extends AuthRequest { - constructor (options) { - super(options) - this.authenticator = options.authenticator - this.authMethod = options.authMethod - } - - static fromParams (req, res) { - const options = AuthRequest.requestOptions(req, res) - return new SharingRequest(options) - } - - static async get (req, res, next) { - const request = SharingRequest.fromParams(req, res) - const appUrl = request.getAppUrl() - if (!appUrl) return next() - const appOrigin = appUrl.origin - const serverUrl = new url.URL(req.app.locals.ldp.serverUri) - if (request.isUserLoggedIn()) { - if ( - !request.isSubdomain(serverUrl.host, new url.URL(request.session.subject._id).host) || - (appUrl && request.isSubdomain(serverUrl.host, appUrl.host) && appUrl.protocol === serverUrl.protocol) || - await request.isAppRegistered(req.app.locals.ldp, appOrigin, request.session.subject._id) - ) { - request.setUserShared(appOrigin) - request.redirectPostSharing() - } else { - request.renderForm(null, req, appOrigin) - } - } else { - request.redirectPostSharing() - } - } - - static async share (req, res) { - let accessModes = [] - let consented = false - if (req.body) { - accessModes = req.body.access_mode || [] - if (!Array.isArray(accessModes)) { - accessModes = [accessModes] - } - consented = req.body.consent - } - const request = SharingRequest.fromParams(req, res) - if (request.isUserLoggedIn()) { - const appUrl = request.getAppUrl() - const appOrigin = `${appUrl.protocol}//${appUrl.host}` - debug('Sharing App') - if (consented) { - await request.registerApp(req.app.locals.ldp, appOrigin, accessModes, request.session.subject._id) - request.setUserShared(appOrigin) - } - request.redirectPostSharing() - } else { - request.redirectPostSharing() - } - } - - isSubdomain (domain, subdomain) { - const domainArr = domain.split('.') - const subdomainArr = subdomain.split('.') - for (let i = 1; i <= domainArr.length; i++) { - if (subdomainArr[subdomainArr.length - i] !== domainArr[domainArr.length - i]) { - return false - } - } - return true - } - - setUserShared (appOrigin) { - if (!this.session.consentedOrigins) { - this.session.consentedOrigins = [] - } - if (!this.session.consentedOrigins.includes(appOrigin)) { - this.session.consentedOrigins.push(appOrigin) - } - } - - isUserLoggedIn () { - return !!(this.session.subject && this.session.subject._id) - } - - getAppUrl () { - if (!this.authQueryParams.redirect_uri) return - return new url.URL(this.authQueryParams.redirect_uri) - } - - async getProfileGraph (ldp, webId) { - const store = $rdf.graph() - const profileText = await ldp.readResource(webId) - return new Promise((resolve, reject) => { - $rdf.parse(profileText.toString(), store, this.getWebIdFile(webId), 'text/turtle', (error, kb) => { - if (error) { - reject(error) - } else { - resolve(kb) - } - }) - }) - } - - async saveProfileGraph (ldp, store, webId) { - const text = $rdf.serialize(undefined, store, this.getWebIdFile(webId), 'text/turtle') - await ldp.put(webId, intoStream(text), 'text/turtle') - } - - getWebIdFile (webId) { - const webIdurl = new url.URL(webId) - return `${webIdurl.origin}${webIdurl.pathname}` - } - - async isAppRegistered (ldp, appOrigin, webId) { - const store = await this.getProfileGraph(ldp, webId) - return store.each($rdf.sym(webId), ACL('trustedApp')).find((app) => { - return store.each(app, ACL('origin')).find(rdfAppOrigin => rdfAppOrigin.value === appOrigin) - }) - } - - async registerApp (ldp, appOrigin, accessModes, webId) { - debug(`Registering app (${appOrigin}) with accessModes ${accessModes} for webId ${webId}`) - const store = await this.getProfileGraph(ldp, webId) - const origin = $rdf.sym(appOrigin) - store.statementsMatching(null, ACL('origin'), origin).forEach(st => { - store.removeStatements([...store.statementsMatching(null, ACL('trustedApp'), st.subject)]) - store.removeStatements([...store.statementsMatching(st.subject)]) - }) - const application = new $rdf.BlankNode() - store.add($rdf.sym(webId), ACL('trustedApp'), application, new $rdf.NamedNode(webId)) - store.add(application, ACL('origin'), origin, new $rdf.NamedNode(webId)) - accessModes.forEach(mode => { - store.add(application, ACL('mode'), ACL(mode)) - }) - await this.saveProfileGraph(ldp, store, webId) - } - - postSharingUrl () { - return this.authorizeUrl() - } - - redirectPostSharing () { - const uri = this.postSharingUrl() - debug('Login successful, redirecting to ', uri) - this.response.redirect(uri) - } - - renderForm (error, req, appOrigin) { - const queryString = (req && req.url && req.url.replace(/[^?]+\?/, '')) || '' - const params = Object.assign({}, this.authQueryParams, { - registerUrl: this.registerUrl(), - returnToUrl: this.returnToUrl, - enablePassword: this.localAuth.password, - enableTls: this.localAuth.tls, - tlsUrl: `/login/tls?${encodeURIComponent(queryString)}`, - app_origin: appOrigin - }) - if (error) { - params.error = error.message - this.response.status(error.statusCode) - } - this.response.render('auth/sharing', params) - } -} - -export default SharingRequest +import debugModule from '../debug.js' +import AuthRequest from './auth-request.js' +import url from 'url' +import intoStream from 'into-stream' +import * as $rdf from 'rdflib' + +const debug = debugModule.authentication +const ACL = $rdf.Namespace('http://www.w3.org/ns/auth/acl#') + +export class SharingRequest extends AuthRequest { + constructor (options) { + super(options) + this.authenticator = options.authenticator + this.authMethod = options.authMethod + } + + static fromParams (req, res) { + const options = AuthRequest.requestOptions(req, res) + return new SharingRequest(options) + } + + static async get (req, res, next) { + const request = SharingRequest.fromParams(req, res) + const appUrl = request.getAppUrl() + if (!appUrl) return next() + const appOrigin = appUrl.origin + const serverUrl = new url.URL(req.app.locals.ldp.serverUri) + if (request.isUserLoggedIn()) { + if ( + !request.isSubdomain(serverUrl.host, new url.URL(request.session.subject._id).host) || + (appUrl && request.isSubdomain(serverUrl.host, appUrl.host) && appUrl.protocol === serverUrl.protocol) || + await request.isAppRegistered(req.app.locals.ldp, appOrigin, request.session.subject._id) + ) { + request.setUserShared(appOrigin) + request.redirectPostSharing() + } else { + request.renderForm(null, req, appOrigin) + } + } else { + request.redirectPostSharing() + } + } + + static async share (req, res) { + let accessModes = [] + let consented = false + if (req.body) { + accessModes = req.body.access_mode || [] + if (!Array.isArray(accessModes)) { + accessModes = [accessModes] + } + consented = req.body.consent + } + const request = SharingRequest.fromParams(req, res) + if (request.isUserLoggedIn()) { + const appUrl = request.getAppUrl() + const appOrigin = `${appUrl.protocol}//${appUrl.host}` + debug('Sharing App') + if (consented) { + await request.registerApp(req.app.locals.ldp, appOrigin, accessModes, request.session.subject._id) + request.setUserShared(appOrigin) + } + request.redirectPostSharing() + } else { + request.redirectPostSharing() + } + } + + isSubdomain (domain, subdomain) { + const domainArr = domain.split('.') + const subdomainArr = subdomain.split('.') + for (let i = 1; i <= domainArr.length; i++) { + if (subdomainArr[subdomainArr.length - i] !== domainArr[domainArr.length - i]) { + return false + } + } + return true + } + + setUserShared (appOrigin) { + if (!this.session.consentedOrigins) { + this.session.consentedOrigins = [] + } + if (!this.session.consentedOrigins.includes(appOrigin)) { + this.session.consentedOrigins.push(appOrigin) + } + } + + isUserLoggedIn () { + return !!(this.session.subject && this.session.subject._id) + } + + getAppUrl () { + if (!this.authQueryParams.redirect_uri) return + return new url.URL(this.authQueryParams.redirect_uri) + } + + async getProfileGraph (ldp, webId) { + const store = $rdf.graph() + const profileText = await ldp.readResource(webId) + return new Promise((resolve, reject) => { + $rdf.parse(profileText.toString(), store, this.getWebIdFile(webId), 'text/turtle', (error, kb) => { + if (error) { + reject(error) + } else { + resolve(kb) + } + }) + }) + } + + async saveProfileGraph (ldp, store, webId) { + const text = $rdf.serialize(undefined, store, this.getWebIdFile(webId), 'text/turtle') + await ldp.put(webId, intoStream(text), 'text/turtle') + } + + getWebIdFile (webId) { + const webIdurl = new url.URL(webId) + return `${webIdurl.origin}${webIdurl.pathname}` + } + + async isAppRegistered (ldp, appOrigin, webId) { + const store = await this.getProfileGraph(ldp, webId) + return store.each($rdf.sym(webId), ACL('trustedApp')).find((app) => { + return store.each(app, ACL('origin')).find(rdfAppOrigin => rdfAppOrigin.value === appOrigin) + }) + } + + async registerApp (ldp, appOrigin, accessModes, webId) { + debug(`Registering app (${appOrigin}) with accessModes ${accessModes} for webId ${webId}`) + const store = await this.getProfileGraph(ldp, webId) + const origin = $rdf.sym(appOrigin) + store.statementsMatching(null, ACL('origin'), origin).forEach(st => { + store.removeStatements([...store.statementsMatching(null, ACL('trustedApp'), st.subject)]) + store.removeStatements([...store.statementsMatching(st.subject)]) + }) + const application = new $rdf.BlankNode() + store.add($rdf.sym(webId), ACL('trustedApp'), application, new $rdf.NamedNode(webId)) + store.add(application, ACL('origin'), origin, new $rdf.NamedNode(webId)) + accessModes.forEach(mode => { + store.add(application, ACL('mode'), ACL(mode)) + }) + await this.saveProfileGraph(ldp, store, webId) + } + + postSharingUrl () { + return this.authorizeUrl() + } + + redirectPostSharing () { + const uri = this.postSharingUrl() + debug('Login successful, redirecting to ', uri) + this.response.redirect(uri) + } + + renderForm (error, req, appOrigin) { + const queryString = (req && req.url && req.url.replace(/[^?]+\?/, '')) || '' + const params = Object.assign({}, this.authQueryParams, { + registerUrl: this.registerUrl(), + returnToUrl: this.returnToUrl, + enablePassword: this.localAuth.password, + enableTls: this.localAuth.tls, + tlsUrl: `/login/tls?${encodeURIComponent(queryString)}`, + app_origin: appOrigin + }) + if (error) { + params.error = error.message + this.response.status(error.statusCode) + } + this.response.render('auth/sharing', params) + } +} + +export default SharingRequest diff --git a/lib/resource-mapper.mjs b/lib/resource-mapper.js similarity index 99% rename from lib/resource-mapper.mjs rename to lib/resource-mapper.js index 01c16b88d..135135045 100644 --- a/lib/resource-mapper.mjs +++ b/lib/resource-mapper.js @@ -2,7 +2,7 @@ import fs from 'fs' import URL from 'url' import { promisify } from 'util' import mime from 'mime-types' -import HTTPError from './http-error.mjs' +import HTTPError from './http-error.js' const { types, extensions } = mime const readdir = promisify(fs.readdir) diff --git a/lib/server-config.mjs b/lib/server-config.js similarity index 97% rename from lib/server-config.mjs rename to lib/server-config.js index 539802b32..4383c0d38 100644 --- a/lib/server-config.mjs +++ b/lib/server-config.js @@ -4,11 +4,11 @@ import fs from 'fs-extra' import path from 'path' -import { processHandlebarFile } from './common/template-utils.mjs' -import { copyTemplateDir } from './common/fs-utils.mjs' +import { processHandlebarFile } from './common/template-utils.js' +import { copyTemplateDir } from './common/fs-utils.js' import { fileURLToPath } from 'url' -import debug from './debug.mjs' +import debug from './debug.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) diff --git a/lib/services/blacklist-service.mjs b/lib/services/blacklist-service.js similarity index 96% rename from lib/services/blacklist-service.mjs rename to lib/services/blacklist-service.js index 7fc39f310..d85c2b519 100644 --- a/lib/services/blacklist-service.mjs +++ b/lib/services/blacklist-service.js @@ -1,36 +1,36 @@ -import { createRequire } from 'module' -import bigUsernameBlacklistPkg from 'the-big-username-blacklist' -const require = createRequire(import.meta.url) -const blacklistConfig = require('../../config/usernames-blacklist.json') -const { list: bigBlacklist } = bigUsernameBlacklistPkg - -class BlacklistService { - constructor () { - this.reset() - } - - addWord (word) { - this.list.push(BlacklistService._prepareWord(word)) - } - - reset (config) { - this.list = BlacklistService._initList(config) - } - - validate (word) { - return this.list.indexOf(BlacklistService._prepareWord(word)) === -1 - } - - static _initList (config = blacklistConfig) { - return [ - ...(config.useTheBigUsernameBlacklist ? bigBlacklist : []), - ...config.customBlacklistedUsernames - ] - } - - static _prepareWord (word) { - return word.trim().toLocaleLowerCase() - } -} - -export default new BlacklistService() +import { createRequire } from 'module' +import bigUsernameBlacklistPkg from 'the-big-username-blacklist' +const require = createRequire(import.meta.url) +const blacklistConfig = require('../../config/usernames-blacklist.json') +const { list: bigBlacklist } = bigUsernameBlacklistPkg + +class BlacklistService { + constructor () { + this.reset() + } + + addWord (word) { + this.list.push(BlacklistService._prepareWord(word)) + } + + reset (config) { + this.list = BlacklistService._initList(config) + } + + validate (word) { + return this.list.indexOf(BlacklistService._prepareWord(word)) === -1 + } + + static _initList (config = blacklistConfig) { + return [ + ...(config.useTheBigUsernameBlacklist ? bigBlacklist : []), + ...config.customBlacklistedUsernames + ] + } + + static _prepareWord (word) { + return word.trim().toLocaleLowerCase() + } +} + +export default new BlacklistService() diff --git a/lib/services/email-service.mjs b/lib/services/email-service.js similarity index 78% rename from lib/services/email-service.mjs rename to lib/services/email-service.js index 11f49b81b..35f909201 100644 --- a/lib/services/email-service.mjs +++ b/lib/services/email-service.js @@ -1,76 +1,75 @@ -import nodemailer from 'nodemailer' -import path from 'path' -import debugModule from '../debug.mjs' -import { pathToFileURL } from 'url' - -const debug = debugModule.email - -class EmailService { - constructor (templatePath, config) { - this.mailer = nodemailer.createTransport(config) - this.sender = this.initSender(config) - this.templatePath = templatePath - } - - initSender (config) { - let sender - if (config.sender) { - sender = config.sender - } else { - sender = `no-reply@${config.host}` - } - return sender - } - - sendMail (email) { - email.from = email.from || this.sender - debug('Sending email to ' + email.to) - return this.mailer.sendMail(email) - } - - sendWithTemplate (templateName, data) { - return Promise.resolve() - .then(async () => { - const renderedEmail = await this.emailFromTemplate(templateName, data) - return this.sendMail(renderedEmail) - }) - } - - async emailFromTemplate (templateName, data) { - const template = await this.readTemplate(templateName) - const renderFn = template.render ?? (typeof template.default === 'function' ? template.default : template.default?.render) - if (!renderFn) throw new Error('Template does not expose a render function: ' + templateName) - return Object.assign({}, renderFn(data), data) - } - - async readTemplate (templateName) { - // Accept legacy `.js` templateName and prefer `.mjs` - let name = templateName - if (name.endsWith('.js')) name = name.replace(/\.js$/, '.mjs') - const templateFile = this.templatePathFor(name) - // Try dynamic import for ESM templates first - try { - const moduleUrl = pathToFileURL(templateFile).href - const mod = await import(moduleUrl) - return mod - } catch (err) { - // Fallback: if consumer passed a CommonJS template name (no .mjs), try requiring it - try { - const { createRequire } = await import('module') - const require = createRequire(import.meta.url) - // If templateName originally had .js, attempt that too - const cjsTemplateFile = this.templatePathFor(templateName) - const required = require(cjsTemplateFile) - return required - } catch (err2) { - throw new Error('Cannot find email template: ' + templateFile) - } - } - } - - templatePathFor (templateName) { - return path.join(this.templatePath, templateName) - } -} - -export default EmailService +import nodemailer from 'nodemailer' +import path from 'path' +import debugModule from '../debug.js' +import { pathToFileURL } from 'url' + +const debug = debugModule.email + +class EmailService { + constructor (templatePath, config) { + this.mailer = nodemailer.createTransport(config) + this.sender = this.initSender(config) + this.templatePath = templatePath + } + + initSender (config) { + let sender + if (config.sender) { + sender = config.sender + } else { + sender = `no-reply@${config.host}` + } + return sender + } + + sendMail (email) { + email.from = email.from || this.sender + debug('Sending email to ' + email.to) + return this.mailer.sendMail(email) + } + + sendWithTemplate (templateName, data) { + return Promise.resolve() + .then(async () => { + const renderedEmail = await this.emailFromTemplate(templateName, data) + return this.sendMail(renderedEmail) + }) + } + + async emailFromTemplate (templateName, data) { + const template = await this.readTemplate(templateName) + const renderFn = template.render ?? (typeof template.default === 'function' ? template.default : template.default?.render) + if (!renderFn) throw new Error('Template does not expose a render function: ' + templateName) + return Object.assign({}, renderFn(data), data) + } + + async readTemplate (templateName) { + const templateFile = this.templatePathFor(templateName) + // Try dynamic import for ESM templates first + try { + const moduleUrl = pathToFileURL(templateFile).href + const mod = await import(moduleUrl) + return mod + } catch (err) { + // Fallback: allow CommonJS templates to use the explicit .cjs extension. + try { + const { createRequire } = await import('module') + const require = createRequire(import.meta.url) + const cjsTemplateName = templateName.endsWith('.js') + ? templateName.replace(/\.js$/, '.cjs') + : templateName + const cjsTemplateFile = this.templatePathFor(cjsTemplateName) + const required = require(cjsTemplateFile) + return required + } catch (err2) { + throw new Error('Cannot find email template: ' + templateFile) + } + } + } + + templatePathFor (templateName) { + return path.join(this.templatePath, templateName) + } +} + +export default EmailService diff --git a/lib/services/token-service.mjs b/lib/services/token-service.js similarity index 96% rename from lib/services/token-service.mjs rename to lib/services/token-service.js index 89c6a2a01..ce6c95ee7 100644 --- a/lib/services/token-service.mjs +++ b/lib/services/token-service.js @@ -1,39 +1,39 @@ -import { ulid } from 'ulid' - -class TokenService { - constructor () { - this.tokens = {} +import { ulid } from 'ulid' + +class TokenService { + constructor () { + this.tokens = {} } - - generate (domain, data = {}) { - const token = ulid() - this.tokens[domain] = this.tokens[domain] || {} - const value = { - exp: new Date(Date.now() + 20 * 60 * 1000) - } - this.tokens[domain][token] = Object.assign({}, value, data) - return token + + generate (domain, data = {}) { + const token = ulid() + this.tokens[domain] = this.tokens[domain] || {} + const value = { + exp: new Date(Date.now() + 20 * 60 * 1000) + } + this.tokens[domain][token] = Object.assign({}, value, data) + return token } - - verify (domain, token) { - const now = new Date() - if (!this.tokens[domain]) { - throw new Error(`Invalid domain for tokens: ${domain}`) - } - const tokenValue = this.tokens[domain][token] - if (tokenValue && now < tokenValue.exp) { - return tokenValue - } else { - return false - } + + verify (domain, token) { + const now = new Date() + if (!this.tokens[domain]) { + throw new Error(`Invalid domain for tokens: ${domain}`) + } + const tokenValue = this.tokens[domain][token] + if (tokenValue && now < tokenValue.exp) { + return tokenValue + } else { + return false + } } - - remove (domain, token) { - if (!this.tokens[domain]) { - throw new Error(`Invalid domain for tokens: ${domain}`) - } - delete this.tokens[domain][token] - } -} - -export default TokenService + + remove (domain, token) { + if (!this.tokens[domain]) { + throw new Error(`Invalid domain for tokens: ${domain}`) + } + delete this.tokens[domain][token] + } +} + +export default TokenService diff --git a/lib/utils.mjs b/lib/utils.js similarity index 95% rename from lib/utils.mjs rename to lib/utils.js index c30b7132f..c8b8aa397 100644 --- a/lib/utils.mjs +++ b/lib/utils.js @@ -1,307 +1,307 @@ -import fs from 'fs' -import path from 'path' -import util from 'util' -import $rdf from 'rdflib' -import from from 'from2' -import url, { fileURLToPath } from 'url' -import debugModule from './debug.mjs' -import getSize from 'get-folder-size' -import vocab from 'solid-namespace' - -const nsObj = vocab($rdf) -const debug = debugModule.fs -/** - * Returns a fully qualified URL from an Express.js Request object. - * (It's insane that Express does not provide this natively.) - * - * Usage: - * - * ``` - * console.log(util.fullUrlForReq(req)) - * // -> https://example.com/path/to/resource?q1=v1 - * ``` - * - * @method fullUrlForReq - * - * @param req {IncomingRequest} Express.js request object - * - * @return {string} Fully qualified URL of the request - */ -export function fullUrlForReq (req) { - const fullUrl = url.format({ - protocol: req.protocol, - host: req.get('host'), - pathname: url.resolve(req.baseUrl, req.path), - query: req.query - }) - - return fullUrl -} - -/** - * Removes the `<` and `>` brackets around a string and returns it. - * Used by the `allow` handler in `verifyDelegator()` logic. - * @method debrack - * - * @param s {string} - * - * @return {string} - */ -export function debrack (s) { - if (!s || s.length < 2) { - return s - } - if (s[0] !== '<') { - return s - } - if (s[s.length - 1] !== '>') { - return s - } - return s.substring(1, s.length - 1) -} - -/** - * Parse RDF content based on content type. - * - * @method parse - * @param graph {Graph} rdflib Graph object to parse into - * @param data {string} Data to parse - * @param base {string} Base URL - * @param contentType {string} Content type - * @return {Graph} The parsed graph - */ -export async function parse (data, baseUri, contentType) { - const graph = $rdf.graph() - return new Promise((resolve, reject) => { - try { - return $rdf.parse(data, graph, baseUri, contentType, (err, str) => { - if (err) { - return reject(err) - } - resolve(str) - }) - } catch (err) { - return reject(err) - } - }) -} - -/** - * Returns the base filename (without directory) for a given path. - * - * @method pathBasename - * - * @param fullpath {string} - * - * @return {string} - */ -export function pathBasename (fullpath) { - let bname = '' - if (fullpath) { - bname = (fullpath.lastIndexOf('/') === fullpath.length - 1) - ? '' - : path.basename(fullpath) - } - return bname -} - -/** - * Checks to see whether a string has the given suffix. - * - * @method hasSuffix - * - * @param str {string} - * @param suffix {string} - * - * @return {boolean} - */ -export function hasSuffix (path, suffixes) { - for (const i in suffixes) { - if (path.indexOf(suffixes[i], path.length - suffixes[i].length) !== -1) { - return true - } - } - return false -} - -/** - * Serializes an `rdflib` graph to a string. - * - * @method serialize - * - * @param graph {Graph} rdflib Graph object - * @param base {string} Base URL - * @param contentType {string} - * - * @return {string} - */ -export function serialize (graph, base, contentType) { - return new Promise((resolve, reject) => { - try { - // target, kb, base, contentType, callback - $rdf.serialize(null, graph, base, contentType, function (err, result) { - if (err) { - return reject(err) - } - if (result === undefined) { - return reject(new Error('Error serializing the graph to ' + - contentType)) - } - - resolve(result) - }) - } catch (err) { - reject(err) - } - }) -} - -/** - * Translates common RDF content types to `rdflib` parser names. - * - * @method translate - * - * @param contentType {string} - * - * @return {string} - */ -export function translate (stream, baseUri, from, to) { - return new Promise((resolve, reject) => { - let data = '' - stream - .on('data', function (chunk) { - data += chunk - }) - .on('end', function () { - const graph = $rdf.graph() - $rdf.parse(data, graph, baseUri, from, function (err) { - if (err) return reject(err) - resolve(serialize(graph, baseUri, to)) - }) - }) - }) -} - -/** - * Converts a given string to a Node.js Readable Stream. - * - * @method stringToStream - * - * @param string {string} - * - * @return {ReadableStream} - */ -export function stringToStream (string) { - return from(function (size, next) { - // if there's no more content - // left in the string, close the stream. - if (!string || string.length <= 0) { - return next(null, null) - } - - // Pull in a new chunk of text, - // removing it from the string. - const chunk = string.slice(0, size) - string = string.slice(size) - - // Emit "chunk" from the stream. - next(null, chunk) - }) -} - -/** - * Removes line ending characters (\n and \r) from a string. - * - * @method stripLineEndings - * @param str {string} - * @return {string} - */ -export function stripLineEndings (obj) { - if (!obj) { return obj } - - return obj.replace(/(\r\n|\n|\r)/gm, '') -} - -/** - * Routes the resolved file. Serves static files with content negotiation. - * - * @method routeResolvedFile - * @param req {IncomingMessage} Express.js request object - * @param res {ServerResponse} Express.js response object - * @param file {string} resolved filename - * @param contentType {string} MIME type of the resolved file - * @param container {boolean} whether this is a container - * @param next {Function} Express.js next callback - */ -export function routeResolvedFile (router, path, file, appendFileName = true) { - const fullPath = appendFileName ? path + file.match(/[^/]+$/) : path - const fullFile = fileURLToPath(import.meta.resolve(file)) - router.get(fullPath, (req, res) => res.sendFile(fullFile)) -} - -/** - * Returns the quota for a user in a root - * @param root - * @param serverUri - * @returns {Promise} The quota in bytes - */ -export async function getQuota (root, serverUri) { - const filename = path.join(root, 'settings/serverSide.ttl') - debug('Reading quota from ' + filename) - let prefs - try { - prefs = await _asyncReadfile(filename) - } catch (error) { - debug('Setting no quota. While reading serverSide.ttl, got ' + error) - return Infinity - } - const graph = $rdf.graph() - const storageUri = serverUri.endsWith('/') ? serverUri : serverUri + '/' - try { - $rdf.parse(prefs, graph, storageUri, 'text/turtle') - } catch (error) { - throw new Error('Failed to parse serverSide.ttl, got ' + error) - } - return Number(graph.anyValue($rdf.sym(storageUri), nsObj.solid('storageQuota'))) || Infinity -} - -/** - * Returns true of the user has already exceeded their quota, i.e. it - * will check if new requests should be rejected, which means they - * could PUT a large file and get away with it. - */ -export async function overQuota (root, serverUri) { - const quota = await getQuota(root, serverUri) - if (quota === Infinity) { - return false - } - // TODO: cache this value? - const size = await actualSize(root) - return (size > quota) -} - -/** - * Returns the number of bytes that is occupied by the actual files in - * the file system. IMPORTANT NOTE: Since it traverses the directory - * to find the actual file sizes, this does a costly operation, but - * neglible for the small quotas we currently allow. If the quotas - * grow bigger, this will significantly reduce write performance, and - * so it needs to be rewritten. - */ -function actualSize (root) { - return util.promisify(getSize)(root) -} - -function _asyncReadfile (filename) { - return util.promisify(fs.readFile)(filename, 'utf-8') -} - -/** - * Get the content type from a headers object - * @param headers An Express or Fetch API headers object - * @return {string} A content type string - */ -export function getContentType (headers) { - const value = headers.get ? headers.get('content-type') : headers['content-type'] - return value ? value.replace(/;.*/, '') : '' -} +import fs from 'fs' +import path from 'path' +import util from 'util' +import $rdf from 'rdflib' +import from from 'from2' +import url, { fileURLToPath } from 'url' +import debugModule from './debug.js' +import getSize from 'get-folder-size' +import vocab from 'solid-namespace' + +const nsObj = vocab($rdf) +const debug = debugModule.fs +/** + * Returns a fully qualified URL from an Express.js Request object. + * (It's insane that Express does not provide this natively.) + * + * Usage: + * + * ``` + * console.log(util.fullUrlForReq(req)) + * // -> https://example.com/path/to/resource?q1=v1 + * ``` + * + * @method fullUrlForReq + * + * @param req {IncomingRequest} Express.js request object + * + * @return {string} Fully qualified URL of the request + */ +export function fullUrlForReq (req) { + const fullUrl = url.format({ + protocol: req.protocol, + host: req.get('host'), + pathname: url.resolve(req.baseUrl, req.path), + query: req.query + }) + + return fullUrl +} + +/** + * Removes the `<` and `>` brackets around a string and returns it. + * Used by the `allow` handler in `verifyDelegator()` logic. + * @method debrack + * + * @param s {string} + * + * @return {string} + */ +export function debrack (s) { + if (!s || s.length < 2) { + return s + } + if (s[0] !== '<') { + return s + } + if (s[s.length - 1] !== '>') { + return s + } + return s.substring(1, s.length - 1) +} + +/** + * Parse RDF content based on content type. + * + * @method parse + * @param graph {Graph} rdflib Graph object to parse into + * @param data {string} Data to parse + * @param base {string} Base URL + * @param contentType {string} Content type + * @return {Graph} The parsed graph + */ +export async function parse (data, baseUri, contentType) { + const graph = $rdf.graph() + return new Promise((resolve, reject) => { + try { + return $rdf.parse(data, graph, baseUri, contentType, (err, str) => { + if (err) { + return reject(err) + } + resolve(str) + }) + } catch (err) { + return reject(err) + } + }) +} + +/** + * Returns the base filename (without directory) for a given path. + * + * @method pathBasename + * + * @param fullpath {string} + * + * @return {string} + */ +export function pathBasename (fullpath) { + let bname = '' + if (fullpath) { + bname = (fullpath.lastIndexOf('/') === fullpath.length - 1) + ? '' + : path.basename(fullpath) + } + return bname +} + +/** + * Checks to see whether a string has the given suffix. + * + * @method hasSuffix + * + * @param str {string} + * @param suffix {string} + * + * @return {boolean} + */ +export function hasSuffix (path, suffixes) { + for (const i in suffixes) { + if (path.indexOf(suffixes[i], path.length - suffixes[i].length) !== -1) { + return true + } + } + return false +} + +/** + * Serializes an `rdflib` graph to a string. + * + * @method serialize + * + * @param graph {Graph} rdflib Graph object + * @param base {string} Base URL + * @param contentType {string} + * + * @return {string} + */ +export function serialize (graph, base, contentType) { + return new Promise((resolve, reject) => { + try { + // target, kb, base, contentType, callback + $rdf.serialize(null, graph, base, contentType, function (err, result) { + if (err) { + return reject(err) + } + if (result === undefined) { + return reject(new Error('Error serializing the graph to ' + + contentType)) + } + + resolve(result) + }) + } catch (err) { + reject(err) + } + }) +} + +/** + * Translates common RDF content types to `rdflib` parser names. + * + * @method translate + * + * @param contentType {string} + * + * @return {string} + */ +export function translate (stream, baseUri, from, to) { + return new Promise((resolve, reject) => { + let data = '' + stream + .on('data', function (chunk) { + data += chunk + }) + .on('end', function () { + const graph = $rdf.graph() + $rdf.parse(data, graph, baseUri, from, function (err) { + if (err) return reject(err) + resolve(serialize(graph, baseUri, to)) + }) + }) + }) +} + +/** + * Converts a given string to a Node.js Readable Stream. + * + * @method stringToStream + * + * @param string {string} + * + * @return {ReadableStream} + */ +export function stringToStream (string) { + return from(function (size, next) { + // if there's no more content + // left in the string, close the stream. + if (!string || string.length <= 0) { + return next(null, null) + } + + // Pull in a new chunk of text, + // removing it from the string. + const chunk = string.slice(0, size) + string = string.slice(size) + + // Emit "chunk" from the stream. + next(null, chunk) + }) +} + +/** + * Removes line ending characters (\n and \r) from a string. + * + * @method stripLineEndings + * @param str {string} + * @return {string} + */ +export function stripLineEndings (obj) { + if (!obj) { return obj } + + return obj.replace(/(\r\n|\n|\r)/gm, '') +} + +/** + * Routes the resolved file. Serves static files with content negotiation. + * + * @method routeResolvedFile + * @param req {IncomingMessage} Express.js request object + * @param res {ServerResponse} Express.js response object + * @param file {string} resolved filename + * @param contentType {string} MIME type of the resolved file + * @param container {boolean} whether this is a container + * @param next {Function} Express.js next callback + */ +export function routeResolvedFile (router, path, file, appendFileName = true) { + const fullPath = appendFileName ? path + file.match(/[^/]+$/) : path + const fullFile = fileURLToPath(import.meta.resolve(file)) + router.get(fullPath, (req, res) => res.sendFile(fullFile)) +} + +/** + * Returns the quota for a user in a root + * @param root + * @param serverUri + * @returns {Promise} The quota in bytes + */ +export async function getQuota (root, serverUri) { + const filename = path.join(root, 'settings/serverSide.ttl') + debug('Reading quota from ' + filename) + let prefs + try { + prefs = await _asyncReadfile(filename) + } catch (error) { + debug('Setting no quota. While reading serverSide.ttl, got ' + error) + return Infinity + } + const graph = $rdf.graph() + const storageUri = serverUri.endsWith('/') ? serverUri : serverUri + '/' + try { + $rdf.parse(prefs, graph, storageUri, 'text/turtle') + } catch (error) { + throw new Error('Failed to parse serverSide.ttl, got ' + error) + } + return Number(graph.anyValue($rdf.sym(storageUri), nsObj.solid('storageQuota'))) || Infinity +} + +/** + * Returns true of the user has already exceeded their quota, i.e. it + * will check if new requests should be rejected, which means they + * could PUT a large file and get away with it. + */ +export async function overQuota (root, serverUri) { + const quota = await getQuota(root, serverUri) + if (quota === Infinity) { + return false + } + // TODO: cache this value? + const size = await actualSize(root) + return (size > quota) +} + +/** + * Returns the number of bytes that is occupied by the actual files in + * the file system. IMPORTANT NOTE: Since it traverses the directory + * to find the actual file sizes, this does a costly operation, but + * neglible for the small quotas we currently allow. If the quotas + * grow bigger, this will significantly reduce write performance, and + * so it needs to be rewritten. + */ +function actualSize (root) { + return util.promisify(getSize)(root) +} + +function _asyncReadfile (filename) { + return util.promisify(fs.readFile)(filename, 'utf-8') +} + +/** + * Get the content type from a headers object + * @param headers An Express or Fetch API headers object + * @return {string} A content type string + */ +export function getContentType (headers) { + const value = headers.get ? headers.get('content-type') : headers['content-type'] + return value ? value.replace(/;.*/, '') : '' +} diff --git a/lib/webid/index.mjs b/lib/webid/index.js similarity index 78% rename from lib/webid/index.mjs rename to lib/webid/index.js index 8dc74f5a1..f7024cfa7 100644 --- a/lib/webid/index.mjs +++ b/lib/webid/index.js @@ -1,9 +1,9 @@ -import tls from './tls/index.mjs' - -export default function webid (type) { - type = type || 'tls' - if (type === 'tls') { - return tls - } - throw new Error('No other WebID supported') -} +import tls from './tls/index.js' + +export default function webid (type) { + type = type || 'tls' + if (type === 'tls') { + return tls + } + throw new Error('No other WebID supported') +} diff --git a/lib/webid/lib/get.mjs b/lib/webid/lib/get.js similarity index 96% rename from lib/webid/lib/get.mjs rename to lib/webid/lib/get.js index 1865a0ce9..150182def 100644 --- a/lib/webid/lib/get.mjs +++ b/lib/webid/lib/get.js @@ -1,30 +1,30 @@ -import { URL } from 'url' - -export default function get (webid, callback) { - let uri - try { - uri = new URL(webid) - } catch (err) { - return callback(new Error('Invalid WebID URI: ' + webid + ': ' + err.message)) - } - const headers = { - Accept: 'text/turtle, application/ld+json' - } - fetch(uri.href, { method: 'GET', headers }) - .then(async res => { - if (!res.ok) { - return callback(new Error('Failed to retrieve WebID from ' + uri.href + ': HTTP ' + res.status)) - } - const contentType = res.headers.get('content-type') - let body - if (contentType && contentType.includes('json')) { - body = JSON.stringify(await res.json(), null, 2) - } else { - body = await res.text() - } - callback(null, body, contentType) - }) - .catch(err => { - return callback(new Error('Failed to fetch profile from ' + uri.href + ': ' + err)) - }) -} +import { URL } from 'url' + +export default function get (webid, callback) { + let uri + try { + uri = new URL(webid) + } catch (err) { + return callback(new Error('Invalid WebID URI: ' + webid + ': ' + err.message)) + } + const headers = { + Accept: 'text/turtle, application/ld+json' + } + fetch(uri.href, { method: 'GET', headers }) + .then(async res => { + if (!res.ok) { + return callback(new Error('Failed to retrieve WebID from ' + uri.href + ': HTTP ' + res.status)) + } + const contentType = res.headers.get('content-type') + let body + if (contentType && contentType.includes('json')) { + body = JSON.stringify(await res.json(), null, 2) + } else { + body = await res.text() + } + callback(null, body, contentType) + }) + .catch(err => { + return callback(new Error('Failed to fetch profile from ' + uri.href + ': ' + err)) + }) +} diff --git a/lib/webid/lib/parse.mjs b/lib/webid/lib/parse.js similarity index 96% rename from lib/webid/lib/parse.mjs rename to lib/webid/lib/parse.js index 7083dcefb..5dadbcb5f 100644 --- a/lib/webid/lib/parse.mjs +++ b/lib/webid/lib/parse.js @@ -1,10 +1,10 @@ -import $rdf from 'rdflib' - -export default function parse (profile, graph, uri, mimeType, callback) { - try { - $rdf.parse(profile, graph, uri, mimeType) - return callback(null, graph) - } catch (e) { - return callback(new Error('Could not load/parse profile data: ' + e)) - } -} +import $rdf from 'rdflib' + +export default function parse (profile, graph, uri, mimeType, callback) { + try { + $rdf.parse(profile, graph, uri, mimeType) + return callback(null, graph) + } catch (e) { + return callback(new Error('Could not load/parse profile data: ' + e)) + } +} diff --git a/lib/webid/lib/verify.mjs b/lib/webid/lib/verify.js similarity index 94% rename from lib/webid/lib/verify.mjs rename to lib/webid/lib/verify.js index 210b0c74c..40ffdada0 100644 --- a/lib/webid/lib/verify.mjs +++ b/lib/webid/lib/verify.js @@ -1,77 +1,77 @@ -import $rdf from 'rdflib' -import get from './get.mjs' -import parse from './parse.mjs' - -const Graph = $rdf.graph -const SPARQL_QUERY = 'PREFIX cert: SELECT ?webid ?m ?e WHERE { ?webid cert:key ?key . ?key cert:modulus ?m . ?key cert:exponent ?e . }' - -export function verify (certificateObj, callback) { - if (!certificateObj) { - return callback(new Error('No certificate given')) - } - const uris = getUris(certificateObj) - if (uris.length === 0) { - return callback(new Error('Empty Subject Alternative Name field in certificate')) - } - const uri = uris.shift() - get(uri, function (err, body, contentType) { - if (err) { - return callback(err) - } - verifyKey(certificateObj, uri, body, contentType, function (err, success) { - return callback(err, uri) - }) - }) -} - -function getUris (certificateObj) { - const uris = [] - if (certificateObj && certificateObj.subjectaltname) { - certificateObj.subjectaltname.replace(/URI:([^, ]+)/g, function (match, uri) { - return uris.push(uri) - }) - } - return uris -} - -export function verifyKey (certificateObj, uri, profile, contentType, callback) { - const graph = new Graph() - let found = false - if (!certificateObj.modulus) { - return callback(new Error('Missing modulus value in client certificate')) - } - if (!certificateObj.exponent) { - return callback(new Error('Missing exponent value in client certificate')) - } - if (!contentType) { - return callback(new Error('No value specified for the Content-Type header')) - } - const mimeType = contentType.replace(/;.*/, '') - parse(profile, graph, uri, mimeType, function (err) { - if (err) { - return callback(err) - } - const certExponent = parseInt(certificateObj.exponent, 16).toString() - const query = $rdf.SPARQLToQuery(SPARQL_QUERY, undefined, graph) - graph.query( - query, - function (result) { - if (found) { - return - } - const modulus = result['?m'].value - const exponent = result['?e'].value - if (modulus != null && exponent != null && (modulus.toLowerCase() === certificateObj.modulus.toLowerCase()) && exponent === certExponent) { - found = true - } - }, - undefined, - function () { - if (!found) { - return callback(new Error("Certificate public key not found in the user's profile")) - } - return callback(null, true) - } - ) - }) -} +import $rdf from 'rdflib' +import get from './get.js' +import parse from './parse.js' + +const Graph = $rdf.graph +const SPARQL_QUERY = 'PREFIX cert: SELECT ?webid ?m ?e WHERE { ?webid cert:key ?key . ?key cert:modulus ?m . ?key cert:exponent ?e . }' + +export function verify (certificateObj, callback) { + if (!certificateObj) { + return callback(new Error('No certificate given')) + } + const uris = getUris(certificateObj) + if (uris.length === 0) { + return callback(new Error('Empty Subject Alternative Name field in certificate')) + } + const uri = uris.shift() + get(uri, function (err, body, contentType) { + if (err) { + return callback(err) + } + verifyKey(certificateObj, uri, body, contentType, function (err, success) { + return callback(err, uri) + }) + }) +} + +function getUris (certificateObj) { + const uris = [] + if (certificateObj && certificateObj.subjectaltname) { + certificateObj.subjectaltname.replace(/URI:([^, ]+)/g, function (match, uri) { + return uris.push(uri) + }) + } + return uris +} + +export function verifyKey (certificateObj, uri, profile, contentType, callback) { + const graph = new Graph() + let found = false + if (!certificateObj.modulus) { + return callback(new Error('Missing modulus value in client certificate')) + } + if (!certificateObj.exponent) { + return callback(new Error('Missing exponent value in client certificate')) + } + if (!contentType) { + return callback(new Error('No value specified for the Content-Type header')) + } + const mimeType = contentType.replace(/;.*/, '') + parse(profile, graph, uri, mimeType, function (err) { + if (err) { + return callback(err) + } + const certExponent = parseInt(certificateObj.exponent, 16).toString() + const query = $rdf.SPARQLToQuery(SPARQL_QUERY, undefined, graph) + graph.query( + query, + function (result) { + if (found) { + return + } + const modulus = result['?m'].value + const exponent = result['?e'].value + if (modulus != null && exponent != null && (modulus.toLowerCase() === certificateObj.modulus.toLowerCase()) && exponent === certExponent) { + found = true + } + }, + undefined, + function () { + if (!found) { + return callback(new Error("Certificate public key not found in the user's profile")) + } + return callback(null, true) + } + ) + }) +} diff --git a/lib/webid/tls/generate.mjs b/lib/webid/tls/generate.js similarity index 97% rename from lib/webid/tls/generate.mjs rename to lib/webid/tls/generate.js index 80c9a407e..03259df1c 100644 --- a/lib/webid/tls/generate.mjs +++ b/lib/webid/tls/generate.js @@ -1,53 +1,53 @@ -import forge from 'node-forge' -import { URL } from 'url' -import crypto from 'crypto' - -const certificate = new crypto.Certificate() -const pki = forge.pki - -export function generate (options, callback) { - if (!options.agent) { - return callback(new Error('No agent uri found')) - } - if (!options.spkac) { - return callback(new Error('No public key found'), null) - } - if (!certificate.verifySpkac(Buffer.from(options.spkac))) { - return callback(new Error('Invalid SPKAC')) - } - options.duration = options.duration || 10 - const cert = pki.createCertificate() - cert.serialNumber = (Date.now()).toString(16) - const publicKey = certificate.exportPublicKey(options.spkac).toString() - cert.publicKey = pki.publicKeyFromPem(publicKey) - cert.validity.notBefore = new Date() - cert.validity.notAfter = new Date() - cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + options.duration) - const commonName = options.commonName || new URL(options.agent).hostname - const attrsSubject = [ - { name: 'commonName', value: commonName }, - { name: 'organizationName', value: options.organizationName || 'WebID' } - ] - const attrsIssuer = [ - { name: 'commonName', value: commonName }, - { name: 'organizationName', value: options.organizationName || 'WebID' } - ] - if (options.issuer) { - if (options.issuer.commonName) { - attrsIssuer[0].value = options.issuer.commonName - } - if (options.issuer.organizationName) { - attrsIssuer[1].value = options.issuer.organizationName - } - } - cert.setSubject(attrsSubject) - cert.setIssuer(attrsIssuer) - cert.setExtensions([ - { name: 'basicConstraints', cA: false, critical: true }, - { name: 'subjectAltName', altNames: [{ type: 6, value: options.agent }] }, - { name: 'subjectKeyIdentifier' } - ]) - const keys = pki.rsa.generateKeyPair(1024) - cert.sign(keys.privateKey, forge.md.sha256.create()) - return callback(null, cert) -} +import forge from 'node-forge' +import { URL } from 'url' +import crypto from 'crypto' + +const certificate = new crypto.Certificate() +const pki = forge.pki + +export function generate (options, callback) { + if (!options.agent) { + return callback(new Error('No agent uri found')) + } + if (!options.spkac) { + return callback(new Error('No public key found'), null) + } + if (!certificate.verifySpkac(Buffer.from(options.spkac))) { + return callback(new Error('Invalid SPKAC')) + } + options.duration = options.duration || 10 + const cert = pki.createCertificate() + cert.serialNumber = (Date.now()).toString(16) + const publicKey = certificate.exportPublicKey(options.spkac).toString() + cert.publicKey = pki.publicKeyFromPem(publicKey) + cert.validity.notBefore = new Date() + cert.validity.notAfter = new Date() + cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + options.duration) + const commonName = options.commonName || new URL(options.agent).hostname + const attrsSubject = [ + { name: 'commonName', value: commonName }, + { name: 'organizationName', value: options.organizationName || 'WebID' } + ] + const attrsIssuer = [ + { name: 'commonName', value: commonName }, + { name: 'organizationName', value: options.organizationName || 'WebID' } + ] + if (options.issuer) { + if (options.issuer.commonName) { + attrsIssuer[0].value = options.issuer.commonName + } + if (options.issuer.organizationName) { + attrsIssuer[1].value = options.issuer.organizationName + } + } + cert.setSubject(attrsSubject) + cert.setIssuer(attrsIssuer) + cert.setExtensions([ + { name: 'basicConstraints', cA: false, critical: true }, + { name: 'subjectAltName', altNames: [{ type: 6, value: options.agent }] }, + { name: 'subjectKeyIdentifier' } + ]) + const keys = pki.rsa.generateKeyPair(1024) + cert.sign(keys.privateKey, forge.md.sha256.create()) + return callback(null, cert) +} diff --git a/lib/webid/tls/index.mjs b/lib/webid/tls/index.js similarity index 56% rename from lib/webid/tls/index.mjs rename to lib/webid/tls/index.js index fd591ce72..03783d010 100644 --- a/lib/webid/tls/index.mjs +++ b/lib/webid/tls/index.js @@ -1,6 +1,6 @@ -import * as verifyModule from '../lib/verify.mjs' -import * as generateModule from './generate.mjs' - -export const verify = verifyModule.verify -export const generate = generateModule.generate -export const verifyKey = verifyModule.verifyKey +import * as verifyModule from '../lib/verify.js' +import * as generateModule from './generate.js' + +export const verify = verifyModule.verify +export const generate = generateModule.generate +export const verifyKey = verifyModule.verifyKey diff --git a/package-lock.json b/package-lock.json index b672a4d3c..a562f6e5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "solid-server", "version": "6.0.0", "license": "MIT", + "type": "module", "dependencies": { "@fastify/busboy": "^3.2.0", "@fastify/pre-commit": "^2.2.1", diff --git a/package.json b/package.json index 942cbe791..bc9e6b2ec 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ } ], "license": "MIT", + "type": "module", "repository": { "type": "git", "url": "https://github.com/solid/node-solid-server" @@ -145,35 +146,35 @@ "pre-commit": [ "lint" ], - "main": "index.mjs", + "main": "index.js", "exports": { ".": { - "import": "./index.mjs", - "require": "./index.js" + "import": "./index.js", + "require": "./index.cjs" } }, "scripts": { "build": "echo nothing to build", "solid": "node ./bin/solid", - "lint": "eslint \"**/*.mjs\"", - "lint-fix": "eslint --fix \"**/*.mjs\"", - "validate": "node ./test/validate-turtle.mjs", + "lint": "eslint \"**/*.js\"", + "lint-fix": "eslint --fix \"**/*.js\"", + "validate": "node ./test/validate-turtle.js", "c8": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 c8 --reporter=text-summary mocha --recursive test/unit/ test/integration/", "mocha": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/unit/ test/integration/", - "mocha-integration": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/http-test.mjs", - "mocha-account-creation-oidc": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/account-creation-oidc-test.mjs", - "mocha-account-manager": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/account-manager-test.mjs", - "mocha-account-template": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/account-template-test.mjs", - "mocha-acl-oidc": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/acl-oidc-test.mjs", - "mocha-authentication-oidc": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/authentication-oidc-test.mjs", - "mocha-header": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/header-test.mjs", - "mocha-ldp": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/ldp-test.mjs", + "mocha-integration": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/http-test.js", + "mocha-account-creation-oidc": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/account-creation-oidc-test.js", + "mocha-account-manager": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/account-manager-test.js", + "mocha-account-template": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/account-template-test.js", + "mocha-acl-oidc": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/acl-oidc-test.js", + "mocha-authentication-oidc": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/authentication-oidc-test.js", + "mocha-header": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/header-test.js", + "mocha-ldp": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha --recursive test/integration/ldp-test.js", "prepublishOnly": "npm test", "postpublish": "git push --follow-tags", "test": "npm run lint && npm run validate && npm run c8", - "test-unit": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha test/unit/**/*.mjs --timeout 10000", - "test-integration": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha test/integration/**/*.mjs --timeout 15000", - "test-performance": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha test/performance/**/*.mjs --timeout 10000", + "test-unit": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha test/unit/**/*.js --timeout 10000", + "test-integration": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha test/integration/**/*.js --timeout 15000", + "test-performance": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 mocha test/performance/**/*.js --timeout 10000", "test-all": "npm run test", "clean": "rimraf config/templates config/views", "reset": "rimraf .db data && npm run clean" @@ -184,7 +185,6 @@ "text-summary" ], "include": [ - "lib/**/*.mjs", "lib/**/*.js" ], "exclude": [ diff --git a/test/index.mjs b/test/index.js similarity index 95% rename from test/index.mjs rename to test/index.js index bf46ef1e3..b6e9dee00 100644 --- a/test/index.mjs +++ b/test/index.js @@ -1,167 +1,167 @@ -import fs from 'fs-extra' -import rimraf from 'rimraf' -import path from 'path' -import { fileURLToPath } from 'url' -import OIDCProvider from '@solid/oidc-op' -import dns from 'dns' -import ldnode from '../../index.mjs' -// import ldnode from '../index.mjs' -import supertest from 'supertest' -import https from 'https' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const TEST_HOSTS = ['nic.localhost', 'tim.localhost', 'nicola.localhost'] - -export function rm (file) { - return rimraf.sync(path.normalize(path.join(__dirname, '../resources/' + file))) -} - -export function cleanDir (dirPath) { - fs.removeSync(path.normalize(path.join(dirPath, '.well-known/.acl'))) - fs.removeSync(path.normalize(path.join(dirPath, '.acl'))) - fs.removeSync(path.normalize(path.join(dirPath, 'favicon.ico'))) - fs.removeSync(path.normalize(path.join(dirPath, 'favicon.ico.acl'))) - fs.removeSync(path.normalize(path.join(dirPath, 'index.html'))) - fs.removeSync(path.normalize(path.join(dirPath, 'index.html.acl'))) - fs.removeSync(path.normalize(path.join(dirPath, 'robots.txt'))) - fs.removeSync(path.normalize(path.join(dirPath, 'robots.txt.acl'))) -} - -export function write (text, file) { - return fs.writeFileSync(path.normalize(path.join(__dirname, '../resources/' + file)), text) -} - -export function cp (src, dest) { - return fs.copySync( - path.normalize(path.join(__dirname, '../resources/' + src)), - path.normalize(path.join(__dirname, '../resources/' + dest))) -} - -export function read (file) { - return fs.readFileSync(path.normalize(path.join(__dirname, '../resources/' + file)), { - encoding: 'utf8' - }) -} - -// Backs up the given file -export function backup (src) { - cp(src, src + '.bak') -} - -// Restores a backup of the given file -export function restore (src) { - cp(src + '.bak', src) - rm(src + '.bak') -} - -// Verifies that all HOSTS entries are present -export function checkDnsSettings () { - return Promise.all(TEST_HOSTS.map(hostname => { - return new Promise((resolve, reject) => { - dns.lookup(hostname, (error, ip) => { - if (error || (ip !== '127.0.0.1' && ip !== '::1')) { - reject(error) - } else { - resolve(true) - } - }) - }) - })) - .catch(() => { - throw new Error(`Expected HOSTS entries of 127.0.0.1 for ${TEST_HOSTS.join()}`) - }) -} - -/** - * @param configPath {string} - * - * @returns {Promise} - */ -export function loadProvider (configPath) { - return Promise.resolve() - .then(async () => { - const { default: config } = await import(configPath) - - const provider = new OIDCProvider(config) - - return provider.initializeKeyChain(config.keys) - }) -} - -export { createServer } -function createServer (options) { - return ldnode.createServer(options) -} - -export { setupSupertestServer } -function setupSupertestServer (options) { - const ldpServer = ldnode.createServer(options) - return supertest(ldpServer) -} - -// Lightweight adapter to replace `request` with `node-fetch` in tests -// Supports signatures: -// - request(options, cb) -// - request(url, options, cb) -// And methods: get, post, put, patch, head, delete, del -function buildAgentFn (options = {}) { - const aOpts = options.agentOptions || {} - if (!aOpts || (!aOpts.cert && !aOpts.key)) { - return undefined - } - const httpsAgent = new https.Agent({ - cert: aOpts.cert, - key: aOpts.key, - // Tests often run with NODE_TLS_REJECT_UNAUTHORIZED=0; mirror that here - rejectUnauthorized: false - }) - return (parsedURL) => parsedURL.protocol === 'https:' ? httpsAgent : undefined -} - -async function doFetch (method, url, options = {}, cb) { - try { - const headers = options.headers || {} - const body = options.body - const agent = buildAgentFn(options) - const res = await fetch(url, { method, headers, body, agent }) - // Build a response object similar to `request`'s - const headersObj = {} - res.headers.forEach((value, key) => { headersObj[key] = value }) - const response = { - statusCode: res.status, - statusMessage: res.statusText, - headers: headersObj - } - const hasBody = method !== 'HEAD' - const text = hasBody ? await res.text() : '' - cb(null, response, text) - } catch (err) { - cb(err) - } -} - -function requestAdapter (arg1, arg2, arg3) { - let url, options, cb - if (typeof arg1 === 'string') { - url = arg1 - options = arg2 || {} - cb = arg3 - } else { - options = arg1 || {} - url = options.url - cb = arg2 - } - const method = (options && options.method) || 'GET' - return doFetch(method, url, options, cb) -} - -;['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE'].forEach(m => { - const name = m.toLowerCase() - requestAdapter[name] = (options, cb) => doFetch(m, options.url, options, cb) -}) -// Alias -requestAdapter.del = requestAdapter.delete - -export const httpRequest = requestAdapter +import fs from 'fs-extra' +import rimraf from 'rimraf' +import path from 'path' +import { fileURLToPath } from 'url' +import OIDCProvider from '@solid/oidc-op' +import dns from 'dns' +import ldnode from '../../index.js' +// import ldnode from '../index.js' +import supertest from 'supertest' +import https from 'https' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const TEST_HOSTS = ['nic.localhost', 'tim.localhost', 'nicola.localhost'] + +export function rm (file) { + return rimraf.sync(path.normalize(path.join(__dirname, '../resources/' + file))) +} + +export function cleanDir (dirPath) { + fs.removeSync(path.normalize(path.join(dirPath, '.well-known/.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, '.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, 'favicon.ico'))) + fs.removeSync(path.normalize(path.join(dirPath, 'favicon.ico.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, 'index.html'))) + fs.removeSync(path.normalize(path.join(dirPath, 'index.html.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, 'robots.txt'))) + fs.removeSync(path.normalize(path.join(dirPath, 'robots.txt.acl'))) +} + +export function write (text, file) { + return fs.writeFileSync(path.normalize(path.join(__dirname, '../resources/' + file)), text) +} + +export function cp (src, dest) { + return fs.copySync( + path.normalize(path.join(__dirname, '../resources/' + src)), + path.normalize(path.join(__dirname, '../resources/' + dest))) +} + +export function read (file) { + return fs.readFileSync(path.normalize(path.join(__dirname, '../resources/' + file)), { + encoding: 'utf8' + }) +} + +// Backs up the given file +export function backup (src) { + cp(src, src + '.bak') +} + +// Restores a backup of the given file +export function restore (src) { + cp(src + '.bak', src) + rm(src + '.bak') +} + +// Verifies that all HOSTS entries are present +export function checkDnsSettings () { + return Promise.all(TEST_HOSTS.map(hostname => { + return new Promise((resolve, reject) => { + dns.lookup(hostname, (error, ip) => { + if (error || (ip !== '127.0.0.1' && ip !== '::1')) { + reject(error) + } else { + resolve(true) + } + }) + }) + })) + .catch(() => { + throw new Error(`Expected HOSTS entries of 127.0.0.1 for ${TEST_HOSTS.join()}`) + }) +} + +/** + * @param configPath {string} + * + * @returns {Promise} + */ +export function loadProvider (configPath) { + return Promise.resolve() + .then(async () => { + const { default: config } = await import(configPath) + + const provider = new OIDCProvider(config) + + return provider.initializeKeyChain(config.keys) + }) +} + +export { createServer } +function createServer (options) { + return ldnode.createServer(options) +} + +export { setupSupertestServer } +function setupSupertestServer (options) { + const ldpServer = ldnode.createServer(options) + return supertest(ldpServer) +} + +// Lightweight adapter to replace `request` with `node-fetch` in tests +// Supports signatures: +// - request(options, cb) +// - request(url, options, cb) +// And methods: get, post, put, patch, head, delete, del +function buildAgentFn (options = {}) { + const aOpts = options.agentOptions || {} + if (!aOpts || (!aOpts.cert && !aOpts.key)) { + return undefined + } + const httpsAgent = new https.Agent({ + cert: aOpts.cert, + key: aOpts.key, + // Tests often run with NODE_TLS_REJECT_UNAUTHORIZED=0; mirror that here + rejectUnauthorized: false + }) + return (parsedURL) => parsedURL.protocol === 'https:' ? httpsAgent : undefined +} + +async function doFetch (method, url, options = {}, cb) { + try { + const headers = options.headers || {} + const body = options.body + const agent = buildAgentFn(options) + const res = await fetch(url, { method, headers, body, agent }) + // Build a response object similar to `request`'s + const headersObj = {} + res.headers.forEach((value, key) => { headersObj[key] = value }) + const response = { + statusCode: res.status, + statusMessage: res.statusText, + headers: headersObj + } + const hasBody = method !== 'HEAD' + const text = hasBody ? await res.text() : '' + cb(null, response, text) + } catch (err) { + cb(err) + } +} + +function requestAdapter (arg1, arg2, arg3) { + let url, options, cb + if (typeof arg1 === 'string') { + url = arg1 + options = arg2 || {} + cb = arg3 + } else { + options = arg1 || {} + url = options.url + cb = arg2 + } + const method = (options && options.method) || 'GET' + return doFetch(method, url, options, cb) +} + +;['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE'].forEach(m => { + const name = m.toLowerCase() + requestAdapter[name] = (options, cb) => doFetch(m, options.url, options, cb) +}) +// Alias +requestAdapter.del = requestAdapter.delete + +export const httpRequest = requestAdapter diff --git a/test/integration/account-creation-tls-test.mjs b/test/integration/account-creation-tls-test.js similarity index 97% rename from test/integration/account-creation-tls-test.mjs rename to test/integration/account-creation-tls-test.js index d5dc443ee..8fde54cb2 100644 --- a/test/integration/account-creation-tls-test.mjs +++ b/test/integration/account-creation-tls-test.js @@ -1,127 +1,127 @@ -// This test file is currently commented out in the original CommonJS version -// Converting to ESM for completeness - -// const supertest = require('supertest') -// // Helper functions for the FS -// const $rdf = require('rdflib') -// -// const { rm, read } = require('../utils') -// const ldnode = require('../../index') -// const fs = require('fs-extra') -// const path = require('path') -// -// describe('AccountManager (TLS account creation tests)', function () { -// var address = 'https://localhost:3457' -// var host = 'localhost:3457' -// var ldpHttpsServer -// let rootPath = path.join(__dirname, '../resources/accounts/') -// var ldp = ldnode.createServer({ -// root: rootPath, -// sslKey: path.join(__dirname, '../keys/key.pem'), -// sslCert: path.join(__dirname, '../keys/cert.pem'), -// auth: 'tls', -// webid: true, -// multiuser: true, -// strictOrigin: true -// }) -// -// before(function (done) { -// ldpHttpsServer = ldp.listen(3457, done) -// }) -// -// after(function () { -// if (ldpHttpsServer) ldpHttpsServer.close() -// }) -// -// describe('Account creation', function () { -// it('should create an account directory', function (done) { -// var subdomain = supertest('https://nicola.' + host) -// subdomain.post('/') -// .send(spkacPost) -// .expect(200) -// .end(function (err, res) { -// var subdomain = supertest('https://nicola.' + host) -// subdomain.head('/') -// .expect(401) -// .end(function (err) { -// done(err) -// }) -// }) -// }) -// -// it('should create a profile for the user', function (done) { -// var subdomain = supertest('https://nicola.' + host) -// subdomain.head('/profile/card') -// .expect(401) -// .end(function (err) { -// done(err) -// }) -// }) -// -// it('should create a preferences file in the account directory', function (done) { -// var subdomain = supertest('https://nicola.' + host) -// subdomain.head('/prefs.ttl') -// .expect(401) -// .end(function (err) { -// done(err) -// }) -// }) -// -// it('should create a workspace container', function (done) { -// var subdomain = supertest('https://nicola.' + host) -// subdomain.head('/Public/') -// .expect(401) -// .end(function (err) { -// done(err) -// }) -// }) -// -// it('should create a private profile file in the settings container', function (done) { -// var subdomain = supertest('https://nicola.' + host) -// subdomain.head('/settings/serverSide.ttl') -// .expect(401) -// .end(function (err) { -// done(err) -// }) -// }) -// -// it('should create a private prefs file in the settings container', function (done) { -// var subdomain = supertest('https://nicola.' + host) -// subdomain.head('/inbox/prefs.ttl') -// .expect(401) -// .end(function (err) { -// done(err) -// }) -// }) -// -// it('should create a private inbox container', function (done) { -// var subdomain = supertest('https://nicola.' + host) -// subdomain.head('/inbox/') -// .expect(401) -// .end(function (err) { -// done(err) -// }) -// }) -// }) -// }) - -// ESM equivalent (all commented out as in original) -// import supertest from 'supertest' -// import $rdf from 'rdflib' -// import { rm, read } from '../../test/utils.js' -// import ldnode from '../../index.js' -// import fs from 'fs-extra' -// import path from 'path' -// import { fileURLToPath } from 'url' -// -// const __filename = fileURLToPath(import.meta.url) -// const __dirname = path.dirname(__filename) - -// Since the entire test is commented out, this ESM file contains no active tests -// This preserves the original behavior while providing ESM format for consistency - -describe('AccountManager (TLS account creation tests) - ESM placeholder', function () { - it('should be a placeholder test (original file is commented out)', function () { - // This test passes to maintain consistency with the commented-out original - }) +// This test file is currently commented out in the original CommonJS version +// Converting to ESM for completeness + +// const supertest = require('supertest') +// // Helper functions for the FS +// const $rdf = require('rdflib') +// +// const { rm, read } = require('../utils') +// const ldnode = require('../../index') +// const fs = require('fs-extra') +// const path = require('path') +// +// describe('AccountManager (TLS account creation tests)', function () { +// var address = 'https://localhost:3457' +// var host = 'localhost:3457' +// var ldpHttpsServer +// let rootPath = path.join(__dirname, '../resources/accounts/') +// var ldp = ldnode.createServer({ +// root: rootPath, +// sslKey: path.join(__dirname, '../keys/key.pem'), +// sslCert: path.join(__dirname, '../keys/cert.pem'), +// auth: 'tls', +// webid: true, +// multiuser: true, +// strictOrigin: true +// }) +// +// before(function (done) { +// ldpHttpsServer = ldp.listen(3457, done) +// }) +// +// after(function () { +// if (ldpHttpsServer) ldpHttpsServer.close() +// }) +// +// describe('Account creation', function () { +// it('should create an account directory', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.post('/') +// .send(spkacPost) +// .expect(200) +// .end(function (err, res) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.head('/') +// .expect(401) +// .end(function (err) { +// done(err) +// }) +// }) +// }) +// +// it('should create a profile for the user', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.head('/profile/card') +// .expect(401) +// .end(function (err) { +// done(err) +// }) +// }) +// +// it('should create a preferences file in the account directory', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.head('/prefs.ttl') +// .expect(401) +// .end(function (err) { +// done(err) +// }) +// }) +// +// it('should create a workspace container', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.head('/Public/') +// .expect(401) +// .end(function (err) { +// done(err) +// }) +// }) +// +// it('should create a private profile file in the settings container', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.head('/settings/serverSide.ttl') +// .expect(401) +// .end(function (err) { +// done(err) +// }) +// }) +// +// it('should create a private prefs file in the settings container', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.head('/inbox/prefs.ttl') +// .expect(401) +// .end(function (err) { +// done(err) +// }) +// }) +// +// it('should create a private inbox container', function (done) { +// var subdomain = supertest('https://nicola.' + host) +// subdomain.head('/inbox/') +// .expect(401) +// .end(function (err) { +// done(err) +// }) +// }) +// }) +// }) + +// ESM equivalent (all commented out as in original) +// import supertest from 'supertest' +// import $rdf from 'rdflib' +// import { rm, read } from '../../test/utils.js' +// import ldnode from '../../index.js' +// import fs from 'fs-extra' +// import path from 'path' +// import { fileURLToPath } from 'url' +// +// const __filename = fileURLToPath(import.meta.url) +// const __dirname = path.dirname(__filename) + +// Since the entire test is commented out, this ESM file contains no active tests +// This preserves the original behavior while providing ESM format for consistency + +describe('AccountManager (TLS account creation tests) - ESM placeholder', function () { + it('should be a placeholder test (original file is commented out)', function () { + // This test passes to maintain consistency with the commented-out original + }) }) diff --git a/test/integration/account-manager-test.mjs b/test/integration/account-manager-test.js similarity index 93% rename from test/integration/account-manager-test.mjs rename to test/integration/account-manager-test.js index 865b174a9..7c98ee57a 100644 --- a/test/integration/account-manager-test.mjs +++ b/test/integration/account-manager-test.js @@ -1,150 +1,150 @@ -import path from 'path' -import { fileURLToPath } from 'url' -import fs from 'fs-extra' -import chai from 'chai' - -import LDP from '../../lib/ldp.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' -import ResourceMapper from '../../lib/resource-mapper.mjs' -const expect = chai.expect -chai.should() - -// ESM __dirname equivalent -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const testAccountsDir = path.join(__dirname, '../../test/resources/accounts/') -const accountTemplatePath = path.join(__dirname, '../../default-templates/new-account/') - -let host - -beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) -}) - -afterEach(() => { - fs.removeSync(path.join(__dirname, '../../test/resources/accounts/alice.example.com')) -}) - -// FIXME #1502 -describe('AccountManager', () => { - // after(() => { - // fs.removeSync(path.join(__dirname, '../resources/accounts/alice.localhost')) - // }) - - describe('accountExists()', () => { - const testHost = SolidHost.from({ serverUri: 'https://localhost' }) - - describe('in multi user mode', () => { - const multiuser = true - const resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - rootPath: path.join(__dirname, '../../test/resources/accounts/'), - includeHost: multiuser - }) - const store = new LDP({ multiuser, resourceMapper }) - const options = { multiuser, store, host: testHost } - const accountManager = AccountManager.from(options) - - it('resolves to true if a directory for the account exists in root', () => { - // Note: test/resources/accounts/tim.localhost/ exists in this repo - return accountManager.accountExists('tim') - .then(exists => { - // console.log('DEBUG tim exists:', exists, typeof exists) - expect(exists).to.not.be.false - }) - }) - - it('resolves to false if a directory for the account does not exist', () => { - // Note: test/resources/accounts/alice.localhost/ does NOT exist - return accountManager.accountExists('alice') - .then(exists => { - // console.log('DEBUG alice exists:', exists, typeof exists) - expect(exists).to.not.be.false - }) - }) - }) - - describe('in single user mode', () => { - const multiuser = false - - it('resolves to true if root .acl exists in root storage', () => { - const resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - includeHost: multiuser, - rootPath: path.join(testAccountsDir, 'tim.localhost') - }) - const store = new LDP({ - multiuser, - resourceMapper - }) - const options = { multiuser, store, host: testHost } - const accountManager = AccountManager.from(options) - - return accountManager.accountExists() - .then(exists => { - expect(exists).to.not.be.false - }) - }) - - it('resolves to false if root .acl does not exist in root storage', () => { - const resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - includeHost: multiuser, - rootPath: testAccountsDir - }) - const store = new LDP({ - multiuser, - resourceMapper - }) - const options = { multiuser, store, host: testHost } - const accountManager = AccountManager.from(options) - - return accountManager.accountExists() - .then(exists => { - expect(exists).to.be.false - }) - }) - }) - }) - - describe('createAccountFor()', () => { - it('should create an account directory', () => { - const multiuser = true - const resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - includeHost: multiuser, - rootPath: testAccountsDir - }) - const store = new LDP({ multiuser, resourceMapper }) - const options = { host, multiuser, store, accountTemplatePath } - const accountManager = AccountManager.from(options) - - const userData = { - username: 'alice', - email: 'alice@example.com', - name: 'Alice Q.' - } - const userAccount = accountManager.userAccountFrom(userData) - const accountDir = accountManager.accountDirFor('alice') - return accountManager.createAccountFor(userAccount) - .then(() => { - return accountManager.accountExists('alice') - }) - .then(found => { - expect(found).to.not.be.false - }) - .then(() => { - const profile = fs.readFileSync(path.join(accountDir, '/profile/card$.ttl'), 'utf8') - expect(profile).to.include('"Alice Q."') - expect(profile).to.include('solid:oidcIssuer') - expect(profile).to.include('') - - const rootAcl = fs.readFileSync(path.join(accountDir, '.acl'), 'utf8') - expect(rootAcl).to.include('') - }) - }) - }) -}) +import path from 'path' +import { fileURLToPath } from 'url' +import fs from 'fs-extra' +import chai from 'chai' + +import LDP from '../../lib/ldp.js' +import SolidHost from '../../lib/models/solid-host.js' +import AccountManager from '../../lib/models/account-manager.js' +import ResourceMapper from '../../lib/resource-mapper.js' +const expect = chai.expect +chai.should() + +// ESM __dirname equivalent +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const testAccountsDir = path.join(__dirname, '../../test/resources/accounts/') +const accountTemplatePath = path.join(__dirname, '../../default-templates/new-account/') + +let host + +beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) +}) + +afterEach(() => { + fs.removeSync(path.join(__dirname, '../../test/resources/accounts/alice.example.com')) +}) + +// FIXME #1502 +describe('AccountManager', () => { + // after(() => { + // fs.removeSync(path.join(__dirname, '../resources/accounts/alice.localhost')) + // }) + + describe('accountExists()', () => { + const testHost = SolidHost.from({ serverUri: 'https://localhost' }) + + describe('in multi user mode', () => { + const multiuser = true + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + rootPath: path.join(__dirname, '../../test/resources/accounts/'), + includeHost: multiuser + }) + const store = new LDP({ multiuser, resourceMapper }) + const options = { multiuser, store, host: testHost } + const accountManager = AccountManager.from(options) + + it('resolves to true if a directory for the account exists in root', () => { + // Note: test/resources/accounts/tim.localhost/ exists in this repo + return accountManager.accountExists('tim') + .then(exists => { + // console.log('DEBUG tim exists:', exists, typeof exists) + expect(exists).to.not.be.false + }) + }) + + it('resolves to false if a directory for the account does not exist', () => { + // Note: test/resources/accounts/alice.localhost/ does NOT exist + return accountManager.accountExists('alice') + .then(exists => { + // console.log('DEBUG alice exists:', exists, typeof exists) + expect(exists).to.not.be.false + }) + }) + }) + + describe('in single user mode', () => { + const multiuser = false + + it('resolves to true if root .acl exists in root storage', () => { + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + includeHost: multiuser, + rootPath: path.join(testAccountsDir, 'tim.localhost') + }) + const store = new LDP({ + multiuser, + resourceMapper + }) + const options = { multiuser, store, host: testHost } + const accountManager = AccountManager.from(options) + + return accountManager.accountExists() + .then(exists => { + expect(exists).to.not.be.false + }) + }) + + it('resolves to false if root .acl does not exist in root storage', () => { + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + includeHost: multiuser, + rootPath: testAccountsDir + }) + const store = new LDP({ + multiuser, + resourceMapper + }) + const options = { multiuser, store, host: testHost } + const accountManager = AccountManager.from(options) + + return accountManager.accountExists() + .then(exists => { + expect(exists).to.be.false + }) + }) + }) + }) + + describe('createAccountFor()', () => { + it('should create an account directory', () => { + const multiuser = true + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + includeHost: multiuser, + rootPath: testAccountsDir + }) + const store = new LDP({ multiuser, resourceMapper }) + const options = { host, multiuser, store, accountTemplatePath } + const accountManager = AccountManager.from(options) + + const userData = { + username: 'alice', + email: 'alice@example.com', + name: 'Alice Q.' + } + const userAccount = accountManager.userAccountFrom(userData) + const accountDir = accountManager.accountDirFor('alice') + return accountManager.createAccountFor(userAccount) + .then(() => { + return accountManager.accountExists('alice') + }) + .then(found => { + expect(found).to.not.be.false + }) + .then(() => { + const profile = fs.readFileSync(path.join(accountDir, '/profile/card$.ttl'), 'utf8') + expect(profile).to.include('"Alice Q."') + expect(profile).to.include('solid:oidcIssuer') + expect(profile).to.include('') + + const rootAcl = fs.readFileSync(path.join(accountDir, '.acl'), 'utf8') + expect(rootAcl).to.include('') + }) + }) + }) +}) diff --git a/test/integration/account-template-test.mjs b/test/integration/account-template-test.js similarity index 94% rename from test/integration/account-template-test.mjs rename to test/integration/account-template-test.js index 2dfb5214d..0122c2385 100644 --- a/test/integration/account-template-test.mjs +++ b/test/integration/account-template-test.js @@ -1,135 +1,135 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import fs from 'fs-extra' -import chai from 'chai' -import sinonChai from 'sinon-chai' - -import AccountTemplate from '../../lib/models/account-template.mjs' -import UserAccount from '../../lib/models/user-account.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.should() - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const templatePath = path.join(__dirname, '../../default-templates/new-account') -const accountPath = path.join(__dirname, '../../test/resources/new-account') - -// FIXME #1502 -describe('AccountTemplate', () => { - beforeEach(() => { - fs.removeSync(accountPath) - }) - - afterEach(() => { - fs.removeSync(accountPath) - }) - - describe('copy()', () => { - it('should copy a directory', () => { - return AccountTemplate.copyTemplateDir(templatePath, accountPath) - .then(() => { - const rootAcl = fs.readFileSync(path.join(accountPath, '.acl'), 'utf8') - expect(rootAcl).to.exist - }) - }) - }) - - describe('processAccount()', () => { - it('should process all the files in an account', () => { - const substitutions = { - webId: 'https://alice.example.com/#me', - email: 'alice@example.com', - name: 'Alice Q.' - } - const template = new AccountTemplate({ substitutions }) - - return AccountTemplate.copyTemplateDir(templatePath, accountPath) - .then(() => { - return template.processAccount(accountPath) - }) - .then(() => { - const profile = fs.readFileSync(path.join(accountPath, '/profile/card$.ttl'), 'utf8') - expect(profile).to.include('"Alice Q."') - expect(profile).to.include('solid:oidcIssuer') - // why does this need to be included? - // with the current configuration, 'host' for - // ldp is not set, therefore solid:oidcIssuer is empty - // expect(profile).to.include('') - - const rootAcl = fs.readFileSync(path.join(accountPath, '.acl'), 'utf8') - expect(rootAcl).to.include('') - }) - }) - }) - - describe('templateSubtitutionsFor()', () => { - it('should not update the webid', () => { - const userAccount = new UserAccount({ - webId: 'https://alice.example.com/#me', - email: 'alice@example.com', - name: 'Alice Q.' - }) - - const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) - - expect(substitutions.webId).to.equal('/#me') - }) - - it('should not update the nested webid', () => { - const userAccount = new UserAccount({ - webId: 'https://alice.example.com/alice/#me', - email: 'alice@example.com', - name: 'Alice Q.' - }) - - const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) - - expect(substitutions.webId).to.equal('/alice/#me') - }) - - it('should update the webid', () => { - const userAccount = new UserAccount({ - webId: 'http://localhost:8443/alice/#me', - email: 'alice@example.com', - name: 'Alice Q.' - }) - - const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) - - expect(substitutions.webId).to.equal('/alice/#me') - }) - }) - - describe('creating account where webId does match server Uri?', () => { - it('should have a relative uri for the base path rather than a complete uri', () => { - const userAccount = new UserAccount({ - webId: 'http://localhost:8443/alice/#me', - email: 'alice@example.com', - name: 'Alice Q.' - }) - - const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) - const template = new AccountTemplate({ substitutions }) - return AccountTemplate.copyTemplateDir(templatePath, accountPath) - .then(() => { - return template.processAccount(accountPath) - }).then(() => { - const profile = fs.readFileSync(path.join(accountPath, '/profile/card$.ttl'), 'utf8') - expect(profile).to.include('"Alice Q."') - expect(profile).to.include('solid:oidcIssuer') - // why does this need to be included? - // with the current configuration, 'host' for - // ldp is not set, therefore solid:oidcIssuer is empty - // expect(profile).to.include('') - - const rootAcl = fs.readFileSync(path.join(accountPath, '.acl'), 'utf8') - expect(rootAcl).to.include('') - }) - }) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import fs from 'fs-extra' +import chai from 'chai' +import sinonChai from 'sinon-chai' + +import AccountTemplate from '../../lib/models/account-template.js' +import UserAccount from '../../lib/models/user-account.js' + +const { expect } = chai +chai.use(sinonChai) +chai.should() + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const templatePath = path.join(__dirname, '../../default-templates/new-account') +const accountPath = path.join(__dirname, '../../test/resources/new-account') + +// FIXME #1502 +describe('AccountTemplate', () => { + beforeEach(() => { + fs.removeSync(accountPath) + }) + + afterEach(() => { + fs.removeSync(accountPath) + }) + + describe('copy()', () => { + it('should copy a directory', () => { + return AccountTemplate.copyTemplateDir(templatePath, accountPath) + .then(() => { + const rootAcl = fs.readFileSync(path.join(accountPath, '.acl'), 'utf8') + expect(rootAcl).to.exist + }) + }) + }) + + describe('processAccount()', () => { + it('should process all the files in an account', () => { + const substitutions = { + webId: 'https://alice.example.com/#me', + email: 'alice@example.com', + name: 'Alice Q.' + } + const template = new AccountTemplate({ substitutions }) + + return AccountTemplate.copyTemplateDir(templatePath, accountPath) + .then(() => { + return template.processAccount(accountPath) + }) + .then(() => { + const profile = fs.readFileSync(path.join(accountPath, '/profile/card$.ttl'), 'utf8') + expect(profile).to.include('"Alice Q."') + expect(profile).to.include('solid:oidcIssuer') + // why does this need to be included? + // with the current configuration, 'host' for + // ldp is not set, therefore solid:oidcIssuer is empty + // expect(profile).to.include('') + + const rootAcl = fs.readFileSync(path.join(accountPath, '.acl'), 'utf8') + expect(rootAcl).to.include('') + }) + }) + }) + + describe('templateSubtitutionsFor()', () => { + it('should not update the webid', () => { + const userAccount = new UserAccount({ + webId: 'https://alice.example.com/#me', + email: 'alice@example.com', + name: 'Alice Q.' + }) + + const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) + + expect(substitutions.webId).to.equal('/#me') + }) + + it('should not update the nested webid', () => { + const userAccount = new UserAccount({ + webId: 'https://alice.example.com/alice/#me', + email: 'alice@example.com', + name: 'Alice Q.' + }) + + const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) + + expect(substitutions.webId).to.equal('/alice/#me') + }) + + it('should update the webid', () => { + const userAccount = new UserAccount({ + webId: 'http://localhost:8443/alice/#me', + email: 'alice@example.com', + name: 'Alice Q.' + }) + + const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) + + expect(substitutions.webId).to.equal('/alice/#me') + }) + }) + + describe('creating account where webId does match server Uri?', () => { + it('should have a relative uri for the base path rather than a complete uri', () => { + const userAccount = new UserAccount({ + webId: 'http://localhost:8443/alice/#me', + email: 'alice@example.com', + name: 'Alice Q.' + }) + + const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) + const template = new AccountTemplate({ substitutions }) + return AccountTemplate.copyTemplateDir(templatePath, accountPath) + .then(() => { + return template.processAccount(accountPath) + }).then(() => { + const profile = fs.readFileSync(path.join(accountPath, '/profile/card$.ttl'), 'utf8') + expect(profile).to.include('"Alice Q."') + expect(profile).to.include('solid:oidcIssuer') + // why does this need to be included? + // with the current configuration, 'host' for + // ldp is not set, therefore solid:oidcIssuer is empty + // expect(profile).to.include('') + + const rootAcl = fs.readFileSync(path.join(accountPath, '.acl'), 'utf8') + expect(rootAcl).to.include('') + }) + }) + }) +}) diff --git a/test/integration/acl-oidc-test.mjs b/test/integration/acl-oidc-test.js similarity index 97% rename from test/integration/acl-oidc-test.mjs rename to test/integration/acl-oidc-test.js index 507a29768..f5058864a 100644 --- a/test/integration/acl-oidc-test.mjs +++ b/test/integration/acl-oidc-test.js @@ -1,1047 +1,1047 @@ -import { assert } from 'chai' -import fs from 'fs-extra' -import path from 'path' -import { fileURLToPath } from 'url' -import { loadProvider, rm, checkDnsSettings, cleanDir } from '../utils.mjs' -import IDToken from '@solid/oidc-op/src/IDToken.js' -// import { clearAclCache } from '../../lib/acl-checker.js' -import ldnode from '../../index.mjs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Helper to mimic request's callback API for get, put, post, head, patch -function fetchRequest (method, options, callback) { - // options: { url, headers, body, ... } - const fetchOptions = { - method: method.toUpperCase(), - headers: options.headers || {}, - body: options.body - } - // For GET/HEAD, don't send body - if (['GET', 'HEAD'].includes(fetchOptions.method)) { - delete fetchOptions.body - } - fetch(options.url, fetchOptions) - .then(async res => { - let body = await res.text() - // Try to parse as JSON if content-type is json - if (res.headers.get('content-type') && res.headers.get('content-type').includes('json')) { - try { body = JSON.parse(body) } catch (e) {} - } - callback(null, { - statusCode: res.status, - headers: Object.fromEntries(res.headers.entries()), - body: body, - statusMessage: res.statusText - }, body) - }) - .catch(err => callback(err)) -} - -function request (options, cb) { - // Allow string URL - if (typeof options === 'string') options = { url: options } - const method = (options.method || 'GET').toLowerCase() - return fetchRequest(method, options, cb) -} - -request.get = (options, cb) => fetchRequest('get', options, cb) -request.put = (options, cb) => fetchRequest('put', options, cb) -request.post = (options, cb) => fetchRequest('post', options, cb) -request.head = (options, cb) => fetchRequest('head', options, cb) -request.patch = (options, cb) => fetchRequest('patch', options, cb) -request.delete = (options, cb) => fetchRequest('delete', options, cb) -request.del = request.delete - -const port = 7777 -const serverUri = 'https://localhost:7777' -const rootPath = path.normalize(path.join(__dirname, '../resources/accounts-acl')) -const dbPath = path.join(rootPath, 'db') -const oidcProviderPath = path.join(dbPath, 'oidc', 'op', 'provider.json') -const configPath = path.join(rootPath, 'config') - -const user1 = 'https://tim.localhost:7777/profile/card#me' -const timAccountUri = 'https://tim.localhost:7777' -const user2 = 'https://nicola.localhost:7777/profile/card#me' - -let oidcProvider - -// To be initialized in the before() block -const userCredentials = { - // idp: https://localhost:7777 - // web id: https://tim.localhost:7777/profile/card#me - user1: '', - // web id: https://nicola.localhost:7777/profile/card#me - user2: '' -} - -function issueIdToken (oidcProvider, webId) { - return Promise.resolve().then(() => { - const jwt = IDToken.issue(oidcProvider, { - sub: webId, - aud: [serverUri, 'client123'], - azp: 'client123' - }) - - return jwt.encode() - }) -} - -const argv = { - root: rootPath, - serverUri, - dbPath, - port, - configPath, - sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), - sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), - webid: true, - multiuser: true, - auth: 'oidc', - strictOrigin: true, - host: { serverUri } -} - -// FIXME #1502 -describe('ACL with WebID+OIDC over HTTP', function () { - let ldp, ldpHttpsServer - - before(checkDnsSettings) - - before(done => { - ldp = ldnode.createServer(argv) - - loadProvider(oidcProviderPath).then(provider => { - oidcProvider = provider - - return Promise.all([ - issueIdToken(oidcProvider, user1), - issueIdToken(oidcProvider, user2) - ]) - }).then(tokens => { - userCredentials.user1 = tokens[0] - userCredentials.user2 = tokens[1] - }).then(() => { - ldpHttpsServer = ldp.listen(port, done) - }).catch(console.error) - }) - - /* afterEach(() => { - clearAclCache() - }) */ - - after(() => { - if (ldpHttpsServer) ldpHttpsServer.close() - cleanDir(rootPath) - }) - - const origin1 = 'http://example.org/' - const origin2 = 'http://example.com/' - - function createOptions (path, user, contentType = 'text/plain') { - const options = { - url: timAccountUri + path, - headers: { - accept: 'text/turtle', - 'content-type': contentType - } - } - if (user) { - const accessToken = userCredentials[user] - options.headers.Authorization = 'Bearer ' + accessToken - } - - return options - } - - describe('no ACL', function () { - it('Should return 500 since no ACL is a server misconfig', function (done) { - const options = createOptions('/no-acl/', 'user1') - request(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 500) - done() - }) - }) - // it('should not have the `User` set in the Response Header', function (done) { - // var options = createOptions('/no-acl/', 'user1') - // request(options, function (error, response, body) { - // assert.equal(error, null) - // assert.notProperty(response.headers, 'user') - // done() - // }) - // }) - }) - - describe('empty .acl', function () { - describe('with no default in parent path', function () { - it('should give no access', function (done) { - const options = createOptions('/empty-acl/test-folder', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user1 as solid:owner should let edit the .acl', function (done) { - const options = createOptions('/empty-acl/.acl', 'user1', 'text/turtle') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 204) - done() - }) - }) - it('user1 as solid:owner should let read the .acl', function (done) { - const options = createOptions('/empty-acl/.acl', 'user1') - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not let edit the .acl', function (done) { - const options = createOptions('/empty-acl/.acl', 'user2', 'text/turtle') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 should not let read the .acl', function (done) { - const options = createOptions('/empty-acl/.acl', 'user2') - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - }) - describe('with default in parent path', function () { - before(function () { - rm('/accounts-acl/tim.localhost/write-acl/empty-acl/another-empty-folder/test-file.acl') - rm('/accounts-acl/tim.localhost/write-acl/empty-acl/test-folder/test-file') - rm('/accounts-acl/tim.localhost/write-acl/empty-acl/test-file') - rm('/accounts-acl/tim.localhost/write-acl/test-file') - rm('/accounts-acl/tim.localhost/write-acl/test-file.acl') - }) - - it('should fail to create a container', function (done) { - const options = createOptions('/write-acl/empty-acl/test-folder/', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) // TODO - why should this be a 409? - done() - }) - }) - it('should fail creation of new files', function (done) { - const options = createOptions('/write-acl/empty-acl/test-file', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('should fail creation of new files in deeper paths', function (done) { - const options = createOptions('/write-acl/empty-acl/test-folder/test-file', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('Should not create empty acl file', function (done) { - const options = createOptions('/write-acl/empty-acl/another-empty-folder/.acl', 'user1', 'text/turtle') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 204) // 403) is this a must ? - done() - }) - }) - it('should return text/turtle for the acl file', function (done) { - const options = createOptions('/write-acl/.acl', 'user1') - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - assert.match(response.headers['content-type'], /text\/turtle/) - done() - }) - }) - it('should fail as acl:default is used to try to authorize', function (done) { - const options = createOptions('/write-acl/bad-acl-access/.acl', 'user1') - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) // 403) is this a must ? - done() - }) - }) - it('should create test file', function (done) { - const options = createOptions('/write-acl/test-file', 'user1') - options.body = ' .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 204) - done() - }) - }) - it('should create test file\'s acl file', function (done) { - const options = createOptions('/write-acl/test-file.acl', 'user1', 'text/turtle') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('should not access test file\'s new empty acl file', function (done) { - const options = createOptions('/write-acl/test-file.acl', 'user1') - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) // 403) is this a must ? - done() - }) - }) - - after(function () { - rm('/accounts-acl/tim.localhost/write-acl/empty-acl/another-empty-folder/test-file.acl') - rm('/accounts-acl/tim.localhost/write-acl/empty-acl/test-folder/test-file') - rm('/accounts-acl/tim.localhost/write-acl/empty-acl/test-file') - rm('/accounts-acl/tim.localhost/write-acl/test-file') - rm('/accounts-acl/tim.localhost/write-acl/test-file.acl') - }) - }) - }) - - describe('no-control', function () { - it('user1 as owner should edit acl file', function (done) { - const options = createOptions('/no-control/.acl', 'user1', 'text/turtle') - options.body = '<#0>' + - '\n a ;' + - '\n ;' + - '\n ;' + - '\n ;' + - '\n .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 204) - done() - }) - }) - it('user2 should not edit acl file', function (done) { - const options = createOptions('/no-control/.acl', 'user2', 'text/turtle') - options.body = '<#0>' + - '\n a ;' + - '\n ;' + - '\n ;' + - '\n ;' + - '\n .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - }) - - describe('Origin', function () { - before(function () { - rm('/accounts-acl/tim.localhost/origin/test-folder/.acl') - }) - - it('should PUT new ACL file', function (done) { - const options = createOptions('/origin/test-folder/.acl', 'user1', 'text/turtle') - options.body = '<#Owner> a ;\n' + - ' ;\n' + - ' <' + user1 + '>;\n' + - ' <' + origin1 + '>;\n' + - ' , , .\n' + - '<#Public> a ;\n' + - ' <./>;\n' + - ' ;\n' + - ' <' + origin1 + '>;\n' + - ' .\n' + - '<#Somebody> a ;\n' + - ' <./>;\n' + - ' <' + user2 + '>;\n' + - ' <./>;\n' + - ' <' + origin1 + '>;\n' + - ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - // TODO triple header - // TODO user header - }) - }) - it('user1 should be able to access test directory', function (done) { - const options = createOptions('/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should be able to access public test directory with wrong origin', function (done) { - const options = createOptions('/origin/test-folder/', 'user2') - options.headers.origin = origin2 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access to test directory when origin is valid', function (done) { - const options = createOptions('/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access public test directory even when origin is invalid', function (done) { - const options = createOptions('/origin/test-folder/', 'user1') - options.headers.origin = origin2 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should be able to access test directory', function (done) { - const options = createOptions('/origin/test-folder/') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should be able to access to test directory when origin is valid', function (done) { - const options = createOptions('/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should be able to access public test directory even when origin is invalid', function (done) { - const options = createOptions('/origin/test-folder/') - options.headers.origin = origin2 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should be able to write to test directory with correct origin', function (done) { - const options = createOptions('/origin/test-folder/test1.txt', 'user2', 'text/plain') - options.headers.origin = origin1 - options.body = 'DAAAAAHUUUT' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user2 should not be able to write to test directory with wrong origin', function (done) { - const options = createOptions('/origin/test-folder/test2.txt', 'user2', 'text/plain') - options.headers.origin = origin2 - options.body = 'ARRRRGH' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'Origin Unauthorized') - done() - }) - }) - - after(function () { - rm('/accounts-acl/tim.localhost/origin/test-folder/.acl') - rm('/accounts-acl/tim.localhost/origin/test-folder/test1.txt') - rm('/accounts-acl/tim.localhost/origin/test-folder/test2.txt') - }) - }) - - describe('Read-only', function () { - const body = fs.readFileSync(path.join(rootPath, 'tim.localhost/read-acl/.acl')) - it('user1 should be able to access ACL file', function (done) { - const options = createOptions('/read-acl/.acl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access test directory', function (done) { - const options = createOptions('/read-acl/', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to modify ACL file', function (done) { - const options = createOptions('/read-acl/.acl', 'user1', 'text/turtle') - options.body = body - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 204) - done() - }) - }) - it('user2 should be able to access test directory', function (done) { - const options = createOptions('/read-acl/', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not be able to access ACL file', function (done) { - const options = createOptions('/read-acl/.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it('user2 should not be able to modify ACL file', function (done) { - const options = createOptions('/read-acl/.acl', 'user2', 'text/turtle') - options.body = ' .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it('agent should be able to access test direcotory', function (done) { - const options = createOptions('/read-acl/') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should not be able to modify ACL file', function (done) { - const options = createOptions('/read-acl/.acl', null, 'text/turtle') - options.body = ' .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - assert.equal(response.statusMessage, 'Unauthenticated') - done() - }) - }) - // Deep acl:accessTo inheritance is not supported yet #963 - it.skip('user1 should be able to access deep test directory ACL', function (done) { - const options = createOptions('/read-acl/deeper-tree/.acl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it.skip('user1 should not be able to access deep test dir', function (done) { - const options = createOptions('/read-acl/deeper-tree/', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it.skip('user1 should able to access even deeper test directory', function (done) { - const options = createOptions('/read-acl/deeper-tree/acls-only-on-top/', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it.skip('user1 should able to access even deeper test file', function (done) { - const options = createOptions('/read-acl/deeper-tree/acls-only-on-top/example.ttl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - }) - - describe('Append-only', function () { - // var body = fs.readFileSync(__dirname + '/resources/append-acl/abc.ttl.acl') - it('user1 should be able to access test file\'s ACL file', function (done) { - const options = createOptions('/append-acl/abc.ttl.acl', 'user1') - request.head(options, function (error, response) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to PATCH a nonexistent resource (which CREATEs)', function (done) { - const options = createOptions('/append-inherited/test.ttl', 'user1') - options.body = 'INSERT DATA { :test :hello 456 .}' - options.headers['content-type'] = 'application/sparql-update' - request.patch(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user1 should be able to PATCH an existing resource', function (done) { - const options = createOptions('/append-inherited/test.ttl', 'user1') - options.body = 'INSERT DATA { :test :hello 789 .}' - options.headers['content-type'] = 'application/sparql-update' - request.patch(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to PUT to non existent resource (which CREATEs)', function (done) { - const options = createOptions('/append-inherited/test1.ttl', 'user1') - options.body = ' .\n' - options.headers['content-type'] = 'text/turtle' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user2 should not be able to PUT with Append (existing resource)', function (done) { - const options = createOptions('/append-inherited/test1.ttl', 'user2') - options.body = ' .\n' - options.headers['content-type'] = 'text/turtle' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.include(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it('user1 should be able to access test file', function (done) { - const options = createOptions('/append-acl/abc.ttl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - // TODO POST instead of PUT - it('user1 should be able to modify test file', function (done) { - const options = createOptions('/append-acl/abc.ttl', 'user1', 'text/turtle') - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 204) - done() - }) - }) - it('user2 should be able to PATCH INSERT to a nonexistent resource (which CREATEs)', function (done) { - const options = createOptions('/append-inherited/new.ttl', 'user2') - options.body = 'INSERT DATA { :test :hello 789 .}' - options.headers['content-type'] = 'application/sparql-update' - request.patch(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user2 should be able to PUT to a non existent resource (which CREATEs)', function (done) { - const options = createOptions('/append-inherited/new1.ttl', 'user1') - options.body = ' .\n' - options.headers['content-type'] = 'text/turtle' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user2 should not be able to access test file\'s ACL file', function (done) { - const options = createOptions('/append-acl/abc.ttl.acl', 'user2', 'text/turtle') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it('user2 should not be able able to post an acl file', function (done) { - const options = createOptions('/append-acl/abc.ttl.acl', 'user2', 'text/turtle') - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it('user2 should not be able to access test file', function (done) { - const options = createOptions('/append-acl/abc.ttl', 'user2', 'text/turtle') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it('user2 (with append permission) cannot use PUT on an existing resource', function (done) { - const options = createOptions('/append-acl/abc.ttl', 'user2', 'text/turtle') - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.include(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it('agent should not be able to access test file', function (done) { - const options = createOptions('/append-acl/abc.ttl') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - assert.equal(response.statusMessage, 'Unauthenticated') - done() - }) - }) - it('agent (with append permissions) should not PUT', function (done) { - const options = createOptions('/append-acl/abc.ttl', null, 'text/turtle') - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - assert.include(response.statusMessage, 'Unauthenticated') - done() - }) - }) - after(function () { - rm('/accounts-acl/tim.localhost/append-inherited/test.ttl') - rm('/accounts-acl/tim.localhost/append-inherited/test1.ttl') - rm('/accounts-acl/tim.localhost/append-inherited/new.ttl') - rm('/accounts-acl/tim.localhost/append-inherited/new1.ttl') - }) - }) - - describe('Group', function () { - // before(function () { - // rm('/accounts-acl/tim.localhost/group/test-folder/.acl') - // }) - - // it('should PUT new ACL file', function (done) { - // var options = createOptions('/group/test-folder/.acl', 'user1') - // options.body = '<#Owner> a ;\n' + - // ' <./.acl>;\n' + - // ' <' + user1 + '>;\n' + - // ' , , .\n' + - // '<#Public> a ;\n' + - // ' <./>;\n' + - // ' ;\n' + - // ' .\n' - // request.put(options, function (error, response, body) { - // assert.equal(error, null) - // assert.equal(response.statusCode, 201) - // done() - // }) - // }) - it('user1 should be able to access test directory', function (done) { - const options = createOptions('/group/test-folder/', 'user1') - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should be able to access test directory', function (done) { - const options = createOptions('/group/test-folder/', 'user2') - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should be able to write a file in the test directory', function (done) { - const options = createOptions('/group/test-folder/test.ttl', 'user2', 'text/turtle') - options.body = '<#Dahut> a .\n' - - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - - it('user1 should be able to get the file', function (done) { - const options = createOptions('/group/test-folder/test.ttl', 'user1', 'text/turtle') - - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not be able to write to the ACL', function (done) { - const options = createOptions('/group/test-folder/.acl', 'user2', 'text/turtle') - options.body = '<#Dahut> a .\n' - - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'User Unauthorized') - done() - }) - }) - - it('user1 should be able to delete the file', function (done) { - const options = createOptions('/group/test-folder/test.ttl', 'user1', 'text/turtle') - - request.delete(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) // Should be 204, right? - done() - }) - }) - it('We should have a 406 with invalid group listings', function (done) { - const options = createOptions('/group/test-folder/some-other-file.txt', 'user2') - - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 406) - done() - }) - }) - it('We should have a 404 for non-existent file', function (done) { - const options = createOptions('/group/test-folder/nothere.txt', 'user2') - - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 404) - done() - }) - }) - }) - - describe('Restricted', function () { - const body = '<#Owner> a ;\n' + - ' <./abc2.ttl>;\n' + - ' <' + user1 + '>;\n' + - ' , , .\n' + - '<#Restricted> a ;\n' + - ' <./abc2.ttl>;\n' + - ' <' + user2 + '>;\n' + - ' , .\n' - it('user1 should be able to modify test file\'s ACL file', function (done) { - const options = createOptions('/append-acl/abc2.ttl.acl', 'user1', 'text/turtle') - options.body = body - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 204) - done() - }) - }) - it('user1 should be able to access test file\'s ACL file', function (done) { - const options = createOptions('/append-acl/abc2.ttl.acl', 'user1', 'text/turtle') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access test file', function (done) { - const options = createOptions('/append-acl/abc2.ttl', 'user1', 'text/turtle') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to modify test file', function (done) { - const options = createOptions('/append-acl/abc2.ttl', 'user1', 'text/turtle') - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 204) - done() - }) - }) - it('user2 should be able to access test file', function (done) { - const options = createOptions('/append-acl/abc2.ttl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not be able to access test file\'s ACL file', function (done) { - const options = createOptions('/append-acl/abc2.ttl.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it('user2 should be able to modify test file', function (done) { - const options = createOptions('/append-acl/abc2.ttl', 'user2', 'text/turtle') - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 204) - done() - }) - }) - it('agent should not be able to access test file', function (done) { - const options = createOptions('/append-acl/abc2.ttl') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - assert.equal(response.statusMessage, 'Unauthenticated') - done() - }) - }) - it('agent should not be able to modify test file', function (done) { - const options = createOptions('/append-acl/abc2.ttl', null, 'text/turtle') - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - assert.equal(response.statusMessage, 'Unauthenticated') - done() - }) - }) - }) - - describe('default', function () { - before(function () { - rm('/accounts-acl/tim.localhost/write-acl/default-for-new/.acl') - rm('/accounts-acl/tim.localhost/write-acl/default-for-new/test-file.ttl') - }) - - const body = '<#Owner> a ;\n' + - ' <./>;\n' + - ' <' + user1 + '>;\n' + - ' <./>;\n' + - ' , , .\n' + - '<#Default> a ;\n' + - ' <./>;\n' + - ' <./>;\n' + - ' ;\n' + - ' .\n' - it('user1 should be able to modify test directory\'s ACL file', function (done) { - const options = createOptions('/write-acl/default-for-new/.acl', 'user1', 'text/turtle') - options.body = body - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user1 should be able to access test direcotory\'s ACL file', function (done) { - const options = createOptions('/write-acl/default-for-new/.acl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to create new test file', function (done) { - const options = createOptions('/write-acl/default-for-new/test-file.ttl', 'user1', 'text/turtle') - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user1 should be able to access new test file', function (done) { - const options = createOptions('/write-acl/default-for-new/test-file.ttl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not be able to access test direcotory\'s ACL file', function (done) { - const options = createOptions('/write-acl/default-for-new/.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it('user2 should be able to access new test file', function (done) { - const options = createOptions('/write-acl/default-for-new/test-file.ttl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not be able to modify new test file', function (done) { - const options = createOptions('/write-acl/default-for-new/test-file.ttl', 'user2', 'text/turtle') - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - assert.equal(response.statusMessage, 'User Unauthorized') - done() - }) - }) - it('agent should be able to access new test file', function (done) { - const options = createOptions('/write-acl/default-for-new/test-file.ttl') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should not be able to modify new test file', function (done) { - const options = createOptions('/write-acl/default-for-new/test-file.ttl', null, 'text/turtle') - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - assert.equal(response.statusMessage, 'Unauthenticated') - done() - }) - }) - - after(function () { - rm('/accounts-acl/tim.localhost/write-acl/default-for-new/.acl') - rm('/accounts-acl/tim.localhost/write-acl/default-for-new/test-file.ttl') - }) - }) - - describe('Wrongly set accessTo', function () { - it('user1 should be able to access test directory', function (done) { - const options = createOptions('/dot-acl/', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - }) -}) +import { assert } from 'chai' +import fs from 'fs-extra' +import path from 'path' +import { fileURLToPath } from 'url' +import { loadProvider, rm, checkDnsSettings, cleanDir } from '../utils.js' +import IDToken from '@solid/oidc-op/src/IDToken.js' +// import { clearAclCache } from '../../lib/acl-checker.js' +import ldnode from '../../index.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Helper to mimic request's callback API for get, put, post, head, patch +function fetchRequest (method, options, callback) { + // options: { url, headers, body, ... } + const fetchOptions = { + method: method.toUpperCase(), + headers: options.headers || {}, + body: options.body + } + // For GET/HEAD, don't send body + if (['GET', 'HEAD'].includes(fetchOptions.method)) { + delete fetchOptions.body + } + fetch(options.url, fetchOptions) + .then(async res => { + let body = await res.text() + // Try to parse as JSON if content-type is json + if (res.headers.get('content-type') && res.headers.get('content-type').includes('json')) { + try { body = JSON.parse(body) } catch (e) {} + } + callback(null, { + statusCode: res.status, + headers: Object.fromEntries(res.headers.entries()), + body: body, + statusMessage: res.statusText + }, body) + }) + .catch(err => callback(err)) +} + +function request (options, cb) { + // Allow string URL + if (typeof options === 'string') options = { url: options } + const method = (options.method || 'GET').toLowerCase() + return fetchRequest(method, options, cb) +} + +request.get = (options, cb) => fetchRequest('get', options, cb) +request.put = (options, cb) => fetchRequest('put', options, cb) +request.post = (options, cb) => fetchRequest('post', options, cb) +request.head = (options, cb) => fetchRequest('head', options, cb) +request.patch = (options, cb) => fetchRequest('patch', options, cb) +request.delete = (options, cb) => fetchRequest('delete', options, cb) +request.del = request.delete + +const port = 7777 +const serverUri = 'https://localhost:7777' +const rootPath = path.normalize(path.join(__dirname, '../resources/accounts-acl')) +const dbPath = path.join(rootPath, 'db') +const oidcProviderPath = path.join(dbPath, 'oidc', 'op', 'provider.json') +const configPath = path.join(rootPath, 'config') + +const user1 = 'https://tim.localhost:7777/profile/card#me' +const timAccountUri = 'https://tim.localhost:7777' +const user2 = 'https://nicola.localhost:7777/profile/card#me' + +let oidcProvider + +// To be initialized in the before() block +const userCredentials = { + // idp: https://localhost:7777 + // web id: https://tim.localhost:7777/profile/card#me + user1: '', + // web id: https://nicola.localhost:7777/profile/card#me + user2: '' +} + +function issueIdToken (oidcProvider, webId) { + return Promise.resolve().then(() => { + const jwt = IDToken.issue(oidcProvider, { + sub: webId, + aud: [serverUri, 'client123'], + azp: 'client123' + }) + + return jwt.encode() + }) +} + +const argv = { + root: rootPath, + serverUri, + dbPath, + port, + configPath, + sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), + sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), + webid: true, + multiuser: true, + auth: 'oidc', + strictOrigin: true, + host: { serverUri } +} + +// FIXME #1502 +describe('ACL with WebID+OIDC over HTTP', function () { + let ldp, ldpHttpsServer + + before(checkDnsSettings) + + before(done => { + ldp = ldnode.createServer(argv) + + loadProvider(oidcProviderPath).then(provider => { + oidcProvider = provider + + return Promise.all([ + issueIdToken(oidcProvider, user1), + issueIdToken(oidcProvider, user2) + ]) + }).then(tokens => { + userCredentials.user1 = tokens[0] + userCredentials.user2 = tokens[1] + }).then(() => { + ldpHttpsServer = ldp.listen(port, done) + }).catch(console.error) + }) + + /* afterEach(() => { + clearAclCache() + }) */ + + after(() => { + if (ldpHttpsServer) ldpHttpsServer.close() + cleanDir(rootPath) + }) + + const origin1 = 'http://example.org/' + const origin2 = 'http://example.com/' + + function createOptions (path, user, contentType = 'text/plain') { + const options = { + url: timAccountUri + path, + headers: { + accept: 'text/turtle', + 'content-type': contentType + } + } + if (user) { + const accessToken = userCredentials[user] + options.headers.Authorization = 'Bearer ' + accessToken + } + + return options + } + + describe('no ACL', function () { + it('Should return 500 since no ACL is a server misconfig', function (done) { + const options = createOptions('/no-acl/', 'user1') + request(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 500) + done() + }) + }) + // it('should not have the `User` set in the Response Header', function (done) { + // var options = createOptions('/no-acl/', 'user1') + // request(options, function (error, response, body) { + // assert.equal(error, null) + // assert.notProperty(response.headers, 'user') + // done() + // }) + // }) + }) + + describe('empty .acl', function () { + describe('with no default in parent path', function () { + it('should give no access', function (done) { + const options = createOptions('/empty-acl/test-folder', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user1 as solid:owner should let edit the .acl', function (done) { + const options = createOptions('/empty-acl/.acl', 'user1', 'text/turtle') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 204) + done() + }) + }) + it('user1 as solid:owner should let read the .acl', function (done) { + const options = createOptions('/empty-acl/.acl', 'user1') + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not let edit the .acl', function (done) { + const options = createOptions('/empty-acl/.acl', 'user2', 'text/turtle') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 should not let read the .acl', function (done) { + const options = createOptions('/empty-acl/.acl', 'user2') + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + }) + describe('with default in parent path', function () { + before(function () { + rm('/accounts-acl/tim.localhost/write-acl/empty-acl/another-empty-folder/test-file.acl') + rm('/accounts-acl/tim.localhost/write-acl/empty-acl/test-folder/test-file') + rm('/accounts-acl/tim.localhost/write-acl/empty-acl/test-file') + rm('/accounts-acl/tim.localhost/write-acl/test-file') + rm('/accounts-acl/tim.localhost/write-acl/test-file.acl') + }) + + it('should fail to create a container', function (done) { + const options = createOptions('/write-acl/empty-acl/test-folder/', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) // TODO - why should this be a 409? + done() + }) + }) + it('should fail creation of new files', function (done) { + const options = createOptions('/write-acl/empty-acl/test-file', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('should fail creation of new files in deeper paths', function (done) { + const options = createOptions('/write-acl/empty-acl/test-folder/test-file', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('Should not create empty acl file', function (done) { + const options = createOptions('/write-acl/empty-acl/another-empty-folder/.acl', 'user1', 'text/turtle') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 204) // 403) is this a must ? + done() + }) + }) + it('should return text/turtle for the acl file', function (done) { + const options = createOptions('/write-acl/.acl', 'user1') + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + assert.match(response.headers['content-type'], /text\/turtle/) + done() + }) + }) + it('should fail as acl:default is used to try to authorize', function (done) { + const options = createOptions('/write-acl/bad-acl-access/.acl', 'user1') + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) // 403) is this a must ? + done() + }) + }) + it('should create test file', function (done) { + const options = createOptions('/write-acl/test-file', 'user1') + options.body = ' .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 204) + done() + }) + }) + it('should create test file\'s acl file', function (done) { + const options = createOptions('/write-acl/test-file.acl', 'user1', 'text/turtle') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('should not access test file\'s new empty acl file', function (done) { + const options = createOptions('/write-acl/test-file.acl', 'user1') + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) // 403) is this a must ? + done() + }) + }) + + after(function () { + rm('/accounts-acl/tim.localhost/write-acl/empty-acl/another-empty-folder/test-file.acl') + rm('/accounts-acl/tim.localhost/write-acl/empty-acl/test-folder/test-file') + rm('/accounts-acl/tim.localhost/write-acl/empty-acl/test-file') + rm('/accounts-acl/tim.localhost/write-acl/test-file') + rm('/accounts-acl/tim.localhost/write-acl/test-file.acl') + }) + }) + }) + + describe('no-control', function () { + it('user1 as owner should edit acl file', function (done) { + const options = createOptions('/no-control/.acl', 'user1', 'text/turtle') + options.body = '<#0>' + + '\n a ;' + + '\n ;' + + '\n ;' + + '\n ;' + + '\n .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 204) + done() + }) + }) + it('user2 should not edit acl file', function (done) { + const options = createOptions('/no-control/.acl', 'user2', 'text/turtle') + options.body = '<#0>' + + '\n a ;' + + '\n ;' + + '\n ;' + + '\n ;' + + '\n .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + }) + + describe('Origin', function () { + before(function () { + rm('/accounts-acl/tim.localhost/origin/test-folder/.acl') + }) + + it('should PUT new ACL file', function (done) { + const options = createOptions('/origin/test-folder/.acl', 'user1', 'text/turtle') + options.body = '<#Owner> a ;\n' + + ' ;\n' + + ' <' + user1 + '>;\n' + + ' <' + origin1 + '>;\n' + + ' , , .\n' + + '<#Public> a ;\n' + + ' <./>;\n' + + ' ;\n' + + ' <' + origin1 + '>;\n' + + ' .\n' + + '<#Somebody> a ;\n' + + ' <./>;\n' + + ' <' + user2 + '>;\n' + + ' <./>;\n' + + ' <' + origin1 + '>;\n' + + ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + // TODO triple header + // TODO user header + }) + }) + it('user1 should be able to access test directory', function (done) { + const options = createOptions('/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should be able to access public test directory with wrong origin', function (done) { + const options = createOptions('/origin/test-folder/', 'user2') + options.headers.origin = origin2 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access to test directory when origin is valid', function (done) { + const options = createOptions('/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access public test directory even when origin is invalid', function (done) { + const options = createOptions('/origin/test-folder/', 'user1') + options.headers.origin = origin2 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should be able to access test directory', function (done) { + const options = createOptions('/origin/test-folder/') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should be able to access to test directory when origin is valid', function (done) { + const options = createOptions('/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should be able to access public test directory even when origin is invalid', function (done) { + const options = createOptions('/origin/test-folder/') + options.headers.origin = origin2 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should be able to write to test directory with correct origin', function (done) { + const options = createOptions('/origin/test-folder/test1.txt', 'user2', 'text/plain') + options.headers.origin = origin1 + options.body = 'DAAAAAHUUUT' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user2 should not be able to write to test directory with wrong origin', function (done) { + const options = createOptions('/origin/test-folder/test2.txt', 'user2', 'text/plain') + options.headers.origin = origin2 + options.body = 'ARRRRGH' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'Origin Unauthorized') + done() + }) + }) + + after(function () { + rm('/accounts-acl/tim.localhost/origin/test-folder/.acl') + rm('/accounts-acl/tim.localhost/origin/test-folder/test1.txt') + rm('/accounts-acl/tim.localhost/origin/test-folder/test2.txt') + }) + }) + + describe('Read-only', function () { + const body = fs.readFileSync(path.join(rootPath, 'tim.localhost/read-acl/.acl')) + it('user1 should be able to access ACL file', function (done) { + const options = createOptions('/read-acl/.acl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access test directory', function (done) { + const options = createOptions('/read-acl/', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to modify ACL file', function (done) { + const options = createOptions('/read-acl/.acl', 'user1', 'text/turtle') + options.body = body + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 204) + done() + }) + }) + it('user2 should be able to access test directory', function (done) { + const options = createOptions('/read-acl/', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not be able to access ACL file', function (done) { + const options = createOptions('/read-acl/.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it('user2 should not be able to modify ACL file', function (done) { + const options = createOptions('/read-acl/.acl', 'user2', 'text/turtle') + options.body = ' .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it('agent should be able to access test direcotory', function (done) { + const options = createOptions('/read-acl/') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should not be able to modify ACL file', function (done) { + const options = createOptions('/read-acl/.acl', null, 'text/turtle') + options.body = ' .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + assert.equal(response.statusMessage, 'Unauthenticated') + done() + }) + }) + // Deep acl:accessTo inheritance is not supported yet #963 + it.skip('user1 should be able to access deep test directory ACL', function (done) { + const options = createOptions('/read-acl/deeper-tree/.acl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it.skip('user1 should not be able to access deep test dir', function (done) { + const options = createOptions('/read-acl/deeper-tree/', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it.skip('user1 should able to access even deeper test directory', function (done) { + const options = createOptions('/read-acl/deeper-tree/acls-only-on-top/', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it.skip('user1 should able to access even deeper test file', function (done) { + const options = createOptions('/read-acl/deeper-tree/acls-only-on-top/example.ttl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + }) + + describe('Append-only', function () { + // var body = fs.readFileSync(__dirname + '/resources/append-acl/abc.ttl.acl') + it('user1 should be able to access test file\'s ACL file', function (done) { + const options = createOptions('/append-acl/abc.ttl.acl', 'user1') + request.head(options, function (error, response) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to PATCH a nonexistent resource (which CREATEs)', function (done) { + const options = createOptions('/append-inherited/test.ttl', 'user1') + options.body = 'INSERT DATA { :test :hello 456 .}' + options.headers['content-type'] = 'application/sparql-update' + request.patch(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user1 should be able to PATCH an existing resource', function (done) { + const options = createOptions('/append-inherited/test.ttl', 'user1') + options.body = 'INSERT DATA { :test :hello 789 .}' + options.headers['content-type'] = 'application/sparql-update' + request.patch(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to PUT to non existent resource (which CREATEs)', function (done) { + const options = createOptions('/append-inherited/test1.ttl', 'user1') + options.body = ' .\n' + options.headers['content-type'] = 'text/turtle' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user2 should not be able to PUT with Append (existing resource)', function (done) { + const options = createOptions('/append-inherited/test1.ttl', 'user2') + options.body = ' .\n' + options.headers['content-type'] = 'text/turtle' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.include(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it('user1 should be able to access test file', function (done) { + const options = createOptions('/append-acl/abc.ttl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + // TODO POST instead of PUT + it('user1 should be able to modify test file', function (done) { + const options = createOptions('/append-acl/abc.ttl', 'user1', 'text/turtle') + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 204) + done() + }) + }) + it('user2 should be able to PATCH INSERT to a nonexistent resource (which CREATEs)', function (done) { + const options = createOptions('/append-inherited/new.ttl', 'user2') + options.body = 'INSERT DATA { :test :hello 789 .}' + options.headers['content-type'] = 'application/sparql-update' + request.patch(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user2 should be able to PUT to a non existent resource (which CREATEs)', function (done) { + const options = createOptions('/append-inherited/new1.ttl', 'user1') + options.body = ' .\n' + options.headers['content-type'] = 'text/turtle' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user2 should not be able to access test file\'s ACL file', function (done) { + const options = createOptions('/append-acl/abc.ttl.acl', 'user2', 'text/turtle') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it('user2 should not be able able to post an acl file', function (done) { + const options = createOptions('/append-acl/abc.ttl.acl', 'user2', 'text/turtle') + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it('user2 should not be able to access test file', function (done) { + const options = createOptions('/append-acl/abc.ttl', 'user2', 'text/turtle') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it('user2 (with append permission) cannot use PUT on an existing resource', function (done) { + const options = createOptions('/append-acl/abc.ttl', 'user2', 'text/turtle') + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.include(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it('agent should not be able to access test file', function (done) { + const options = createOptions('/append-acl/abc.ttl') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + assert.equal(response.statusMessage, 'Unauthenticated') + done() + }) + }) + it('agent (with append permissions) should not PUT', function (done) { + const options = createOptions('/append-acl/abc.ttl', null, 'text/turtle') + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + assert.include(response.statusMessage, 'Unauthenticated') + done() + }) + }) + after(function () { + rm('/accounts-acl/tim.localhost/append-inherited/test.ttl') + rm('/accounts-acl/tim.localhost/append-inherited/test1.ttl') + rm('/accounts-acl/tim.localhost/append-inherited/new.ttl') + rm('/accounts-acl/tim.localhost/append-inherited/new1.ttl') + }) + }) + + describe('Group', function () { + // before(function () { + // rm('/accounts-acl/tim.localhost/group/test-folder/.acl') + // }) + + // it('should PUT new ACL file', function (done) { + // var options = createOptions('/group/test-folder/.acl', 'user1') + // options.body = '<#Owner> a ;\n' + + // ' <./.acl>;\n' + + // ' <' + user1 + '>;\n' + + // ' , , .\n' + + // '<#Public> a ;\n' + + // ' <./>;\n' + + // ' ;\n' + + // ' .\n' + // request.put(options, function (error, response, body) { + // assert.equal(error, null) + // assert.equal(response.statusCode, 201) + // done() + // }) + // }) + it('user1 should be able to access test directory', function (done) { + const options = createOptions('/group/test-folder/', 'user1') + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should be able to access test directory', function (done) { + const options = createOptions('/group/test-folder/', 'user2') + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should be able to write a file in the test directory', function (done) { + const options = createOptions('/group/test-folder/test.ttl', 'user2', 'text/turtle') + options.body = '<#Dahut> a .\n' + + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + + it('user1 should be able to get the file', function (done) { + const options = createOptions('/group/test-folder/test.ttl', 'user1', 'text/turtle') + + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not be able to write to the ACL', function (done) { + const options = createOptions('/group/test-folder/.acl', 'user2', 'text/turtle') + options.body = '<#Dahut> a .\n' + + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'User Unauthorized') + done() + }) + }) + + it('user1 should be able to delete the file', function (done) { + const options = createOptions('/group/test-folder/test.ttl', 'user1', 'text/turtle') + + request.delete(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) // Should be 204, right? + done() + }) + }) + it('We should have a 406 with invalid group listings', function (done) { + const options = createOptions('/group/test-folder/some-other-file.txt', 'user2') + + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 406) + done() + }) + }) + it('We should have a 404 for non-existent file', function (done) { + const options = createOptions('/group/test-folder/nothere.txt', 'user2') + + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 404) + done() + }) + }) + }) + + describe('Restricted', function () { + const body = '<#Owner> a ;\n' + + ' <./abc2.ttl>;\n' + + ' <' + user1 + '>;\n' + + ' , , .\n' + + '<#Restricted> a ;\n' + + ' <./abc2.ttl>;\n' + + ' <' + user2 + '>;\n' + + ' , .\n' + it('user1 should be able to modify test file\'s ACL file', function (done) { + const options = createOptions('/append-acl/abc2.ttl.acl', 'user1', 'text/turtle') + options.body = body + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 204) + done() + }) + }) + it('user1 should be able to access test file\'s ACL file', function (done) { + const options = createOptions('/append-acl/abc2.ttl.acl', 'user1', 'text/turtle') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access test file', function (done) { + const options = createOptions('/append-acl/abc2.ttl', 'user1', 'text/turtle') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to modify test file', function (done) { + const options = createOptions('/append-acl/abc2.ttl', 'user1', 'text/turtle') + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 204) + done() + }) + }) + it('user2 should be able to access test file', function (done) { + const options = createOptions('/append-acl/abc2.ttl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not be able to access test file\'s ACL file', function (done) { + const options = createOptions('/append-acl/abc2.ttl.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it('user2 should be able to modify test file', function (done) { + const options = createOptions('/append-acl/abc2.ttl', 'user2', 'text/turtle') + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 204) + done() + }) + }) + it('agent should not be able to access test file', function (done) { + const options = createOptions('/append-acl/abc2.ttl') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + assert.equal(response.statusMessage, 'Unauthenticated') + done() + }) + }) + it('agent should not be able to modify test file', function (done) { + const options = createOptions('/append-acl/abc2.ttl', null, 'text/turtle') + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + assert.equal(response.statusMessage, 'Unauthenticated') + done() + }) + }) + }) + + describe('default', function () { + before(function () { + rm('/accounts-acl/tim.localhost/write-acl/default-for-new/.acl') + rm('/accounts-acl/tim.localhost/write-acl/default-for-new/test-file.ttl') + }) + + const body = '<#Owner> a ;\n' + + ' <./>;\n' + + ' <' + user1 + '>;\n' + + ' <./>;\n' + + ' , , .\n' + + '<#Default> a ;\n' + + ' <./>;\n' + + ' <./>;\n' + + ' ;\n' + + ' .\n' + it('user1 should be able to modify test directory\'s ACL file', function (done) { + const options = createOptions('/write-acl/default-for-new/.acl', 'user1', 'text/turtle') + options.body = body + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user1 should be able to access test direcotory\'s ACL file', function (done) { + const options = createOptions('/write-acl/default-for-new/.acl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to create new test file', function (done) { + const options = createOptions('/write-acl/default-for-new/test-file.ttl', 'user1', 'text/turtle') + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user1 should be able to access new test file', function (done) { + const options = createOptions('/write-acl/default-for-new/test-file.ttl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not be able to access test direcotory\'s ACL file', function (done) { + const options = createOptions('/write-acl/default-for-new/.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it('user2 should be able to access new test file', function (done) { + const options = createOptions('/write-acl/default-for-new/test-file.ttl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not be able to modify new test file', function (done) { + const options = createOptions('/write-acl/default-for-new/test-file.ttl', 'user2', 'text/turtle') + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + assert.equal(response.statusMessage, 'User Unauthorized') + done() + }) + }) + it('agent should be able to access new test file', function (done) { + const options = createOptions('/write-acl/default-for-new/test-file.ttl') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should not be able to modify new test file', function (done) { + const options = createOptions('/write-acl/default-for-new/test-file.ttl', null, 'text/turtle') + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + assert.equal(response.statusMessage, 'Unauthenticated') + done() + }) + }) + + after(function () { + rm('/accounts-acl/tim.localhost/write-acl/default-for-new/.acl') + rm('/accounts-acl/tim.localhost/write-acl/default-for-new/test-file.ttl') + }) + }) + + describe('Wrongly set accessTo', function () { + it('user1 should be able to access test directory', function (done) { + const options = createOptions('/dot-acl/', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + }) +}) diff --git a/test/integration/acl-tls-test.mjs b/test/integration/acl-tls-test.js similarity index 97% rename from test/integration/acl-tls-test.mjs rename to test/integration/acl-tls-test.js index 0ee4f6828..8073951cd 100644 --- a/test/integration/acl-tls-test.mjs +++ b/test/integration/acl-tls-test.js @@ -1,964 +1,964 @@ -import { assert } from 'chai' -import fs from 'fs-extra' -import $rdf from 'rdflib' -import { httpRequest as request, cleanDir, rm } from '../utils.mjs' -import path from 'path' -import { fileURLToPath } from 'url' - -/** - * Note: this test suite requires an internet connection, since it actually - * uses remote accounts https://user1.databox.me and https://user2.databox.me - */ - -// Helper functions for the FS -// import { rm } from '../../test/utils.js' -// var write = require('./utils').write -// var cp = require('./utils').cp -// var read = require('./utils').read - -import ldnode from '../../index.mjs' -import solidNamespace from 'solid-namespace' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const ns = solidNamespace($rdf) - -const port = 7777 -const serverUri = 'https://localhost:7777' -const rootPath = path.normalize(path.join(__dirname, '../resources/acl-tls')) -const dbPath = path.join(rootPath, 'db') -const configPath = path.join(rootPath, 'config') - -const aclExtension = '.acl' -const metaExtension = '.meta' - -const testDir = 'acl-tls/testDir' -const testDirAclFile = testDir + '/' + aclExtension -const testDirMetaFile = testDir + '/' + metaExtension - -const abcFile = testDir + '/abc.ttl' - -const globFile = testDir + '/*' - -const origin1 = 'http://example.org/' -const origin2 = 'http://example.com/' - -const user1 = 'https://tim.localhost:7777/profile/card#me' -const user2 = 'https://nicola.localhost:7777/profile/card#me' -const address = 'https://tim.localhost:7777' -const userCredentials = { - user1: { - cert: fs.readFileSync(path.normalize(path.join(__dirname, '../keys/user1-cert.pem'))), - key: fs.readFileSync(path.normalize(path.join(__dirname, '../keys/user1-key.pem'))) - }, - user2: { - cert: fs.readFileSync(path.normalize(path.join(__dirname, '../keys/user2-cert.pem'))), - key: fs.readFileSync(path.normalize(path.join(__dirname, '../keys/user2-key.pem'))) - } -} - -// TODO Remove skip. TLS is currently broken, but is not a priority to fix since -// the current Solid spec does not require supporting webid-tls on the resource -// server. The current spec only requires the resource server to support webid-oidc, -// and it requires the IDP to support webid-tls as a log in method, so that users of -// a webid-tls client certificate can still use their certificate (and not a -// username/password pair or other login method) to "bridge" from webid-tls to -// webid-oidc. -describe.skip('ACL with WebID+TLS', function () { - let ldpHttpsServer - const serverConfig = { - root: rootPath, - serverUri, - dbPath, - port, - configPath, - sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), - sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), - webid: true, - multiuser: true, - auth: 'tls', - rejectUnauthorized: false, - strictOrigin: true, - host: { serverUri } - } - const ldp = ldnode.createServer(serverConfig) - - before(function (done) { - ldpHttpsServer = ldp.listen(port, () => { - setTimeout(() => { - done() - }, 0) - }) - }) - - after(function () { - if (ldpHttpsServer) ldpHttpsServer.close() - cleanDir(rootPath) - }) - - function createOptions (path, user) { - const options = { - url: address + path, - headers: { - accept: 'text/turtle', - 'content-type': 'text/plain' - } - } - if (user) { - options.agentOptions = userCredentials[user] - } - return options - } - - describe('no ACL', function () { - it('should return 500 for any resource', function (done) { - rm('.acl') - const options = createOptions('/acl-tls/no-acl/', 'user1') - request(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 500) - done() - }) - }) - - it('should have `User` set in the Response Header', function (done) { - rm('.acl') - const options = createOptions('/acl-tls/no-acl/', 'user1') - request(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.headers.user, 'https://user1.databox.me/profile/card#me') - done() - }) - }) - - it.skip('should return a 401 and WWW-Authenticate header without credentials', (done) => { - rm('.acl') - const options = { - url: address + '/acl-tls/no-acl/', - headers: { accept: 'text/turtle' } - } - - request(options, (error, response, body) => { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - assert.equal(response.headers['www-authenticate'], 'WebID-TLS realm="https://localhost:8443"') - done() - }) - }) - }) - - describe('empty .acl', function () { - describe('with no default in parent path', function () { - it('should give no access', function (done) { - const options = createOptions('/acl-tls/empty-acl/test-folder', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('should not let edit the .acl', function (done) { - const options = createOptions('/acl-tls/empty-acl/.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('should not let read the .acl', function (done) { - const options = createOptions('/acl-tls/empty-acl/.acl', 'user1') - options.headers = { - accept: 'text/turtle' - } - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - }) - describe('with default in parent path', function () { - before(function () { - rm('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl') - rm('/acl-tls/write-acl/empty-acl/test-folder/test-file') - rm('/acl-tls/write-acl/empty-acl/test-file') - rm('/acl-tls/write-acl/test-file') - rm('/acl-tls/write-acl/test-file.acl') - }) - - it('should fail to create a container', function (done) { - const options = createOptions('/acl-tls/write-acl/empty-acl/test-folder/', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) // TODO: SHOULD THIS RETURN A 409? - done() - }) - }) - it('should not allow creation of new files', function (done) { - const options = createOptions('/acl-tls/write-acl/empty-acl/test-file', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('should not allow creation of new files in deeper paths', function (done) { - const options = createOptions('/acl-tls/write-acl/empty-acl/test-folder/test-file', 'user1') - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('Should not create empty acl file', function (done) { - const options = createOptions('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('should not return text/turtle for the acl file', function (done) { - const options = createOptions('/acl-tls/write-acl/.acl', 'user1') - options.headers = { - accept: 'text/turtle' - } - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - // assert.match(response.headers['content-type'], /text\/turtle/) - done() - }) - }) - it('should create test file', function (done) { - const options = createOptions('/acl-tls/write-acl/test-file', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("should create test file's acl file", function (done) { - const options = createOptions('/acl-tls/write-acl/test-file.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = '' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("should not access test file's acl file", function (done) { - const options = createOptions('/acl-tls/write-acl/test-file.acl', 'user1') - options.headers = { - accept: 'text/turtle' - } - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - // assert.match(response.headers['content-type'], /text\/turtle/) - done() - }) - }) - - after(function () { - rm('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl') - rm('/acl-tls/write-acl/empty-acl/test-folder/test-file') - rm('/acl-tls/write-acl/empty-acl/test-file') - rm('/acl-tls/write-acl/test-file') - rm('/acl-tls/write-acl/test-file.acl') - }) - }) - }) - - describe('Origin', function () { - before(function () { - rm('acl-tls/origin/test-folder/.acl') - }) - - it('should PUT new ACL file', function (done) { - const options = createOptions('/acl-tls/origin/test-folder/.acl', 'user1', 'text/turtle') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = '<#Owner> a ;\n' + - ' ;\n' + - ' <' + user1 + '>;\n' + - ' <' + origin1 + '>;\n' + - ' , , .\n' + - '<#Public> a ;\n' + - ' <./>;\n' + - ' ;\n' + - ' <' + origin1 + '>;\n' + - ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - // TODO triple header - // TODO user header - }) - }) - it('user1 should be able to access test directory', function (done) { - const options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access to test directory when origin is valid', - function (done) { - const options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should not be able to access test directory when origin is invalid', - function (done) { - const options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin2 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('agent not should be able to access test directory', function (done) { - const options = createOptions('/acl-tls/origin/test-folder/') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - it('agent should be able to access to test directory when origin is valid', - function (done) { - const options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should not be able to access test directory when origin is invalid', - function (done) { - const options = createOptions('/acl-tls/origin/test-folder/') - options.headers.origin = origin2 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - - after(function () { - rm('acl-tls/origin/test-folder/.acl') - }) - }) - - describe('Mixed statement Origin', function () { - before(function () { - rm('acl-tls/origin/test-folder/.acl') - }) - - it('should PUT new ACL file', function (done) { - const options = createOptions('/acl-tls/origin/test-folder/.acl', 'user1', 'text/turtle') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = '<#Owner1> a ;\n' + - ' ;\n' + - ' <' + user1 + '>;\n' + - ' , , .\n' + - '<#Owner2> a ;\n' + - ' ;\n' + - ' <' + origin1 + '>;\n' + - ' , , .\n' + - '<#Public> a ;\n' + - ' <./>;\n' + - ' ;\n' + - ' <' + origin1 + '>;\n' + - ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - // TODO triple header - // TODO user header - }) - }) - it('user1 should be able to access test directory', function (done) { - const options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access to test directory when origin is valid', - function (done) { - const options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should not be able to access test directory when origin is invalid', - function (done) { - const options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin2 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('agent should not be able to access test directory for logged in users', function (done) { - const options = createOptions('/acl-tls/origin/test-folder/') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - it('agent should be able to access to test directory when origin is valid', - function (done) { - const options = createOptions('/acl-tls/origin/test-folder/', 'user1') - options.headers.origin = origin1 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should not be able to access test directory when origin is invalid', - function (done) { - const options = createOptions('/acl-tls/origin/test-folder/') - options.headers.origin = origin2 - - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - - after(function () { - rm('acl-tls/origin/test-folder/.acl') - }) - }) - - describe('Read-only', function () { - const body = fs.readFileSync(path.join(__dirname, '../resources/acl-tls/tim.localhost/read-acl/.acl')) - it('user1 should be able to access ACL file', function (done) { - const options = createOptions('/acl-tls/read-acl/.acl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access test directory', function (done) { - const options = createOptions('/acl-tls/read-acl/', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to modify ACL file', function (done) { - const options = createOptions('/acl-tls/read-acl/.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = body - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user2 should be able to access test directory', function (done) { - const options = createOptions('/acl-tls/read-acl/', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not be able to access ACL file', function (done) { - const options = createOptions('/acl-tls/read-acl/.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 should not be able to modify ACL file', function (done) { - const options = createOptions('/acl-tls/read-acl/.acl', 'user2') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('agent should be able to access test direcotory', function (done) { - const options = createOptions('/acl-tls/read-acl/') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should not be able to modify ACL file', function (done) { - const options = createOptions('/acl-tls/read-acl/.acl') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - }) - - describe.skip('Glob', function () { - it('user2 should be able to send glob request', function (done) { - const options = createOptions(globFile, 'user2') - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - const globGraph = $rdf.graph() - $rdf.parse(body, globGraph, address + testDir + '/', 'text/turtle') - const authz = globGraph.the(undefined, undefined, ns.acl('Authorization')) - assert.equal(authz, null) - done() - }) - }) - it('user1 should be able to send glob request', function (done) { - const options = createOptions(globFile, 'user1') - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - const globGraph = $rdf.graph() - $rdf.parse(body, globGraph, address + testDir + '/', 'text/turtle') - const authz = globGraph.the(undefined, undefined, ns.acl('Authorization')) - assert.equal(authz, null) - done() - }) - }) - it('user1 should be able to delete ACL file', function (done) { - const options = createOptions(testDirAclFile, 'user1') - request.del(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - }) - - describe('Append-only', function () { - // var body = fs.readFileSync(__dirname + '/resources/acl-tls/append-acl/abc.ttl.acl') - it("user1 should be able to access test file's ACL file", function (done) { - const options = createOptions('/acl-tls/append-acl/abc.ttl.acl', 'user1') - request.head(options, function (error, response) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it.skip('user1 should be able to PATCH a resource', function (done) { - const options = createOptions('/acl-tls/append-inherited/test.ttl', 'user1') - options.headers = { - 'content-type': 'application/sparql-update' - } - options.body = 'INSERT DATA { :test :hello 456 .}' - request.patch(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access test file', function (done) { - const options = createOptions('/acl-tls/append-acl/abc.ttl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - // TODO POST instead of PUT - it('user1 should be able to modify test file', function (done) { - const options = createOptions('/acl-tls/append-acl/abc.ttl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("user2 should not be able to access test file's ACL file", function (done) { - const options = createOptions('/acl-tls/append-acl/abc.ttl.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 should not be able to access test file', function (done) { - const options = createOptions('/acl-tls/append-acl/abc.ttl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 (with append permission) cannot use PUT to append', function (done) { - const options = createOptions('/acl-tls/append-acl/abc.ttl', 'user2') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('agent should not be able to access test file', function (done) { - const options = createOptions('/acl-tls/append-acl/abc.ttl') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - it('agent (with append permissions) should not PUT', function (done) { - const options = createOptions('/acl-tls/append-acl/abc.ttl') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - after(function () { - rm('acl-tls/append-inherited/test.ttl') - }) - }) - - describe('Restricted', function () { - const body = '<#Owner> a ;\n' + - ' <./abc2.ttl>;\n' + - ' <' + user1 + '>;\n' + - ' , , .\n' + - '<#Restricted> a ;\n' + - ' <./abc2.ttl>;\n' + - ' <' + user2 + '>;\n' + - ' , .\n' - it("user1 should be able to modify test file's ACL file", function (done) { - const options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = body - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("user1 should be able to access test file's ACL file", function (done) { - const options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to access test file', function (done) { - const options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to modify test file', function (done) { - const options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user2 should be able to access test file', function (done) { - const options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it("user2 should not be able to access test file's ACL file", function (done) { - const options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 should be able to modify test file', function (done) { - const options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user2') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('agent should not be able to access test file', function (done) { - const options = createOptions('/acl-tls/append-acl/abc2.ttl') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - it('agent should not be able to modify test file', function (done) { - const options = createOptions('/acl-tls/append-acl/abc2.ttl') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - }) - - describe('default', function () { - before(function () { - rm('/acl-tls/write-acl/default-for-new/.acl') - rm('/acl-tls/write-acl/default-for-new/test-file.ttl') - }) - - const body = '<#Owner> a ;\n' + - ' <./>;\n' + - ' <' + user1 + '>;\n' + - ' <./>;\n' + - ' , , .\n' + - '<#Default> a ;\n' + - ' <./>;\n' + - ' <./>;\n' + - ' ;\n' + - ' .\n' - it("user1 should be able to modify test directory's ACL file", function (done) { - const options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = body - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it("user1 should be able to access test direcotory's ACL file", function (done) { - const options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user1 should be able to create new test file', function (done) { - const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user1') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 201) - done() - }) - }) - it('user1 should be able to access new test file', function (done) { - const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user1') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it("user2 should not be able to access test direcotory's ACL file", function (done) { - const options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('user2 should be able to access new test file', function (done) { - const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user2') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('user2 should not be able to modify new test file', function (done) { - const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user2') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 403) - done() - }) - }) - it('agent should be able to access new test file', function (done) { - const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl') - request.head(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('agent should not be able to modify new test file', function (done) { - const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl') - options.headers = { - 'content-type': 'text/turtle' - } - options.body = ' .\n' - request.put(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 401) - done() - }) - }) - - after(function () { - rm('/acl-tls/write-acl/default-for-new/.acl') - rm('/acl-tls/write-acl/default-for-new/test-file.ttl') - }) - }) - - describe('WebID delegation tests', function () { - it('user1 should be able delegate to user2', function (done) { - // var body = '<' + user1 + '> <' + user2 + '> .' - const options = { - url: user1, - headers: { - 'content-type': 'text/turtle' - }, - agentOptions: { - key: userCredentials.user1.key, - cert: userCredentials.user1.cert - } - } - request.post(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - // it("user2 should be able to make requests on behalf of user1", function(done) { - // var options = createOptions(abcdFile, 'user2') - // options.headers = { - // 'content-type': 'text/turtle', - // 'On-Behalf-Of': '<' + user1 + '>' - // } - // options.body = " ." - // request.post(options, function(error, response, body) { - // assert.equal(error, null) - // assert.equal(response.statusCode, 200) - // done() - // }) - // }) - }) - - describe.skip('Cleanup', function () { - it('should remove all files and dirs created', function (done) { - try { - // must remove the ACLs in sync - fs.unlinkSync(path.join(__dirname, '../resources/' + testDir + '/dir1/dir2/abcd.ttl')) - fs.rmdirSync(path.join(__dirname, '../resources/' + testDir + '/dir1/dir2/')) - fs.rmdirSync(path.join(__dirname, '../resources/' + testDir + '/dir1/')) - fs.unlinkSync(path.join(__dirname, '../resources/' + abcFile)) - fs.unlinkSync(path.join(__dirname, '../resources/' + testDirAclFile)) - fs.unlinkSync(path.join(__dirname, '../resources/' + testDirMetaFile)) - fs.rmdirSync(path.join(__dirname, '../resources/' + testDir)) - fs.rmdirSync(path.join(__dirname, '../resources/acl-tls/')) - done() - } catch (e) { - done(e) - } - }) - }) -}) +import { assert } from 'chai' +import fs from 'fs-extra' +import $rdf from 'rdflib' +import { httpRequest as request, cleanDir, rm } from '../utils.js' +import path from 'path' +import { fileURLToPath } from 'url' + +/** + * Note: this test suite requires an internet connection, since it actually + * uses remote accounts https://user1.databox.me and https://user2.databox.me + */ + +// Helper functions for the FS +// import { rm } from '../../test/utils.js' +// var write = require('./utils').write +// var cp = require('./utils').cp +// var read = require('./utils').read + +import ldnode from '../../index.js' +import solidNamespace from 'solid-namespace' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const ns = solidNamespace($rdf) + +const port = 7777 +const serverUri = 'https://localhost:7777' +const rootPath = path.normalize(path.join(__dirname, '../resources/acl-tls')) +const dbPath = path.join(rootPath, 'db') +const configPath = path.join(rootPath, 'config') + +const aclExtension = '.acl' +const metaExtension = '.meta' + +const testDir = 'acl-tls/testDir' +const testDirAclFile = testDir + '/' + aclExtension +const testDirMetaFile = testDir + '/' + metaExtension + +const abcFile = testDir + '/abc.ttl' + +const globFile = testDir + '/*' + +const origin1 = 'http://example.org/' +const origin2 = 'http://example.com/' + +const user1 = 'https://tim.localhost:7777/profile/card#me' +const user2 = 'https://nicola.localhost:7777/profile/card#me' +const address = 'https://tim.localhost:7777' +const userCredentials = { + user1: { + cert: fs.readFileSync(path.normalize(path.join(__dirname, '../keys/user1-cert.pem'))), + key: fs.readFileSync(path.normalize(path.join(__dirname, '../keys/user1-key.pem'))) + }, + user2: { + cert: fs.readFileSync(path.normalize(path.join(__dirname, '../keys/user2-cert.pem'))), + key: fs.readFileSync(path.normalize(path.join(__dirname, '../keys/user2-key.pem'))) + } +} + +// TODO Remove skip. TLS is currently broken, but is not a priority to fix since +// the current Solid spec does not require supporting webid-tls on the resource +// server. The current spec only requires the resource server to support webid-oidc, +// and it requires the IDP to support webid-tls as a log in method, so that users of +// a webid-tls client certificate can still use their certificate (and not a +// username/password pair or other login method) to "bridge" from webid-tls to +// webid-oidc. +describe.skip('ACL with WebID+TLS', function () { + let ldpHttpsServer + const serverConfig = { + root: rootPath, + serverUri, + dbPath, + port, + configPath, + sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), + sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), + webid: true, + multiuser: true, + auth: 'tls', + rejectUnauthorized: false, + strictOrigin: true, + host: { serverUri } + } + const ldp = ldnode.createServer(serverConfig) + + before(function (done) { + ldpHttpsServer = ldp.listen(port, () => { + setTimeout(() => { + done() + }, 0) + }) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + cleanDir(rootPath) + }) + + function createOptions (path, user) { + const options = { + url: address + path, + headers: { + accept: 'text/turtle', + 'content-type': 'text/plain' + } + } + if (user) { + options.agentOptions = userCredentials[user] + } + return options + } + + describe('no ACL', function () { + it('should return 500 for any resource', function (done) { + rm('.acl') + const options = createOptions('/acl-tls/no-acl/', 'user1') + request(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 500) + done() + }) + }) + + it('should have `User` set in the Response Header', function (done) { + rm('.acl') + const options = createOptions('/acl-tls/no-acl/', 'user1') + request(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.headers.user, 'https://user1.databox.me/profile/card#me') + done() + }) + }) + + it.skip('should return a 401 and WWW-Authenticate header without credentials', (done) => { + rm('.acl') + const options = { + url: address + '/acl-tls/no-acl/', + headers: { accept: 'text/turtle' } + } + + request(options, (error, response, body) => { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + assert.equal(response.headers['www-authenticate'], 'WebID-TLS realm="https://localhost:8443"') + done() + }) + }) + }) + + describe('empty .acl', function () { + describe('with no default in parent path', function () { + it('should give no access', function (done) { + const options = createOptions('/acl-tls/empty-acl/test-folder', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('should not let edit the .acl', function (done) { + const options = createOptions('/acl-tls/empty-acl/.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('should not let read the .acl', function (done) { + const options = createOptions('/acl-tls/empty-acl/.acl', 'user1') + options.headers = { + accept: 'text/turtle' + } + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + }) + describe('with default in parent path', function () { + before(function () { + rm('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl') + rm('/acl-tls/write-acl/empty-acl/test-folder/test-file') + rm('/acl-tls/write-acl/empty-acl/test-file') + rm('/acl-tls/write-acl/test-file') + rm('/acl-tls/write-acl/test-file.acl') + }) + + it('should fail to create a container', function (done) { + const options = createOptions('/acl-tls/write-acl/empty-acl/test-folder/', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) // TODO: SHOULD THIS RETURN A 409? + done() + }) + }) + it('should not allow creation of new files', function (done) { + const options = createOptions('/acl-tls/write-acl/empty-acl/test-file', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('should not allow creation of new files in deeper paths', function (done) { + const options = createOptions('/acl-tls/write-acl/empty-acl/test-folder/test-file', 'user1') + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('Should not create empty acl file', function (done) { + const options = createOptions('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('should not return text/turtle for the acl file', function (done) { + const options = createOptions('/acl-tls/write-acl/.acl', 'user1') + options.headers = { + accept: 'text/turtle' + } + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + // assert.match(response.headers['content-type'], /text\/turtle/) + done() + }) + }) + it('should create test file', function (done) { + const options = createOptions('/acl-tls/write-acl/test-file', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("should create test file's acl file", function (done) { + const options = createOptions('/acl-tls/write-acl/test-file.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = '' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("should not access test file's acl file", function (done) { + const options = createOptions('/acl-tls/write-acl/test-file.acl', 'user1') + options.headers = { + accept: 'text/turtle' + } + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + // assert.match(response.headers['content-type'], /text\/turtle/) + done() + }) + }) + + after(function () { + rm('/acl-tls/write-acl/empty-acl/another-empty-folder/test-file.acl') + rm('/acl-tls/write-acl/empty-acl/test-folder/test-file') + rm('/acl-tls/write-acl/empty-acl/test-file') + rm('/acl-tls/write-acl/test-file') + rm('/acl-tls/write-acl/test-file.acl') + }) + }) + }) + + describe('Origin', function () { + before(function () { + rm('acl-tls/origin/test-folder/.acl') + }) + + it('should PUT new ACL file', function (done) { + const options = createOptions('/acl-tls/origin/test-folder/.acl', 'user1', 'text/turtle') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = '<#Owner> a ;\n' + + ' ;\n' + + ' <' + user1 + '>;\n' + + ' <' + origin1 + '>;\n' + + ' , , .\n' + + '<#Public> a ;\n' + + ' <./>;\n' + + ' ;\n' + + ' <' + origin1 + '>;\n' + + ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + // TODO triple header + // TODO user header + }) + }) + it('user1 should be able to access test directory', function (done) { + const options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access to test directory when origin is valid', + function (done) { + const options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should not be able to access test directory when origin is invalid', + function (done) { + const options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin2 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('agent not should be able to access test directory', function (done) { + const options = createOptions('/acl-tls/origin/test-folder/') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + it('agent should be able to access to test directory when origin is valid', + function (done) { + const options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should not be able to access test directory when origin is invalid', + function (done) { + const options = createOptions('/acl-tls/origin/test-folder/') + options.headers.origin = origin2 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + + after(function () { + rm('acl-tls/origin/test-folder/.acl') + }) + }) + + describe('Mixed statement Origin', function () { + before(function () { + rm('acl-tls/origin/test-folder/.acl') + }) + + it('should PUT new ACL file', function (done) { + const options = createOptions('/acl-tls/origin/test-folder/.acl', 'user1', 'text/turtle') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = '<#Owner1> a ;\n' + + ' ;\n' + + ' <' + user1 + '>;\n' + + ' , , .\n' + + '<#Owner2> a ;\n' + + ' ;\n' + + ' <' + origin1 + '>;\n' + + ' , , .\n' + + '<#Public> a ;\n' + + ' <./>;\n' + + ' ;\n' + + ' <' + origin1 + '>;\n' + + ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + // TODO triple header + // TODO user header + }) + }) + it('user1 should be able to access test directory', function (done) { + const options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access to test directory when origin is valid', + function (done) { + const options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should not be able to access test directory when origin is invalid', + function (done) { + const options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin2 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('agent should not be able to access test directory for logged in users', function (done) { + const options = createOptions('/acl-tls/origin/test-folder/') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + it('agent should be able to access to test directory when origin is valid', + function (done) { + const options = createOptions('/acl-tls/origin/test-folder/', 'user1') + options.headers.origin = origin1 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should not be able to access test directory when origin is invalid', + function (done) { + const options = createOptions('/acl-tls/origin/test-folder/') + options.headers.origin = origin2 + + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + + after(function () { + rm('acl-tls/origin/test-folder/.acl') + }) + }) + + describe('Read-only', function () { + const body = fs.readFileSync(path.join(__dirname, '../resources/acl-tls/tim.localhost/read-acl/.acl')) + it('user1 should be able to access ACL file', function (done) { + const options = createOptions('/acl-tls/read-acl/.acl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access test directory', function (done) { + const options = createOptions('/acl-tls/read-acl/', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to modify ACL file', function (done) { + const options = createOptions('/acl-tls/read-acl/.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = body + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user2 should be able to access test directory', function (done) { + const options = createOptions('/acl-tls/read-acl/', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not be able to access ACL file', function (done) { + const options = createOptions('/acl-tls/read-acl/.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 should not be able to modify ACL file', function (done) { + const options = createOptions('/acl-tls/read-acl/.acl', 'user2') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('agent should be able to access test direcotory', function (done) { + const options = createOptions('/acl-tls/read-acl/') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should not be able to modify ACL file', function (done) { + const options = createOptions('/acl-tls/read-acl/.acl') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + }) + + describe.skip('Glob', function () { + it('user2 should be able to send glob request', function (done) { + const options = createOptions(globFile, 'user2') + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + const globGraph = $rdf.graph() + $rdf.parse(body, globGraph, address + testDir + '/', 'text/turtle') + const authz = globGraph.the(undefined, undefined, ns.acl('Authorization')) + assert.equal(authz, null) + done() + }) + }) + it('user1 should be able to send glob request', function (done) { + const options = createOptions(globFile, 'user1') + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + const globGraph = $rdf.graph() + $rdf.parse(body, globGraph, address + testDir + '/', 'text/turtle') + const authz = globGraph.the(undefined, undefined, ns.acl('Authorization')) + assert.equal(authz, null) + done() + }) + }) + it('user1 should be able to delete ACL file', function (done) { + const options = createOptions(testDirAclFile, 'user1') + request.del(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + }) + + describe('Append-only', function () { + // var body = fs.readFileSync(__dirname + '/resources/acl-tls/append-acl/abc.ttl.acl') + it("user1 should be able to access test file's ACL file", function (done) { + const options = createOptions('/acl-tls/append-acl/abc.ttl.acl', 'user1') + request.head(options, function (error, response) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it.skip('user1 should be able to PATCH a resource', function (done) { + const options = createOptions('/acl-tls/append-inherited/test.ttl', 'user1') + options.headers = { + 'content-type': 'application/sparql-update' + } + options.body = 'INSERT DATA { :test :hello 456 .}' + request.patch(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access test file', function (done) { + const options = createOptions('/acl-tls/append-acl/abc.ttl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + // TODO POST instead of PUT + it('user1 should be able to modify test file', function (done) { + const options = createOptions('/acl-tls/append-acl/abc.ttl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("user2 should not be able to access test file's ACL file", function (done) { + const options = createOptions('/acl-tls/append-acl/abc.ttl.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 should not be able to access test file', function (done) { + const options = createOptions('/acl-tls/append-acl/abc.ttl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 (with append permission) cannot use PUT to append', function (done) { + const options = createOptions('/acl-tls/append-acl/abc.ttl', 'user2') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('agent should not be able to access test file', function (done) { + const options = createOptions('/acl-tls/append-acl/abc.ttl') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + it('agent (with append permissions) should not PUT', function (done) { + const options = createOptions('/acl-tls/append-acl/abc.ttl') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + after(function () { + rm('acl-tls/append-inherited/test.ttl') + }) + }) + + describe('Restricted', function () { + const body = '<#Owner> a ;\n' + + ' <./abc2.ttl>;\n' + + ' <' + user1 + '>;\n' + + ' , , .\n' + + '<#Restricted> a ;\n' + + ' <./abc2.ttl>;\n' + + ' <' + user2 + '>;\n' + + ' , .\n' + it("user1 should be able to modify test file's ACL file", function (done) { + const options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = body + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("user1 should be able to access test file's ACL file", function (done) { + const options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to access test file', function (done) { + const options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to modify test file', function (done) { + const options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user2 should be able to access test file', function (done) { + const options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it("user2 should not be able to access test file's ACL file", function (done) { + const options = createOptions('/acl-tls/append-acl/abc2.ttl.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 should be able to modify test file', function (done) { + const options = createOptions('/acl-tls/append-acl/abc2.ttl', 'user2') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('agent should not be able to access test file', function (done) { + const options = createOptions('/acl-tls/append-acl/abc2.ttl') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + it('agent should not be able to modify test file', function (done) { + const options = createOptions('/acl-tls/append-acl/abc2.ttl') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + }) + + describe('default', function () { + before(function () { + rm('/acl-tls/write-acl/default-for-new/.acl') + rm('/acl-tls/write-acl/default-for-new/test-file.ttl') + }) + + const body = '<#Owner> a ;\n' + + ' <./>;\n' + + ' <' + user1 + '>;\n' + + ' <./>;\n' + + ' , , .\n' + + '<#Default> a ;\n' + + ' <./>;\n' + + ' <./>;\n' + + ' ;\n' + + ' .\n' + it("user1 should be able to modify test directory's ACL file", function (done) { + const options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = body + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it("user1 should be able to access test direcotory's ACL file", function (done) { + const options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user1 should be able to create new test file', function (done) { + const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user1') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 201) + done() + }) + }) + it('user1 should be able to access new test file', function (done) { + const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user1') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it("user2 should not be able to access test direcotory's ACL file", function (done) { + const options = createOptions('/acl-tls/write-acl/default-for-new/.acl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('user2 should be able to access new test file', function (done) { + const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user2') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('user2 should not be able to modify new test file', function (done) { + const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl', 'user2') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 403) + done() + }) + }) + it('agent should be able to access new test file', function (done) { + const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl') + request.head(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('agent should not be able to modify new test file', function (done) { + const options = createOptions('/acl-tls/write-acl/default-for-new/test-file.ttl') + options.headers = { + 'content-type': 'text/turtle' + } + options.body = ' .\n' + request.put(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 401) + done() + }) + }) + + after(function () { + rm('/acl-tls/write-acl/default-for-new/.acl') + rm('/acl-tls/write-acl/default-for-new/test-file.ttl') + }) + }) + + describe('WebID delegation tests', function () { + it('user1 should be able delegate to user2', function (done) { + // var body = '<' + user1 + '> <' + user2 + '> .' + const options = { + url: user1, + headers: { + 'content-type': 'text/turtle' + }, + agentOptions: { + key: userCredentials.user1.key, + cert: userCredentials.user1.cert + } + } + request.post(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + // it("user2 should be able to make requests on behalf of user1", function(done) { + // var options = createOptions(abcdFile, 'user2') + // options.headers = { + // 'content-type': 'text/turtle', + // 'On-Behalf-Of': '<' + user1 + '>' + // } + // options.body = " ." + // request.post(options, function(error, response, body) { + // assert.equal(error, null) + // assert.equal(response.statusCode, 200) + // done() + // }) + // }) + }) + + describe.skip('Cleanup', function () { + it('should remove all files and dirs created', function (done) { + try { + // must remove the ACLs in sync + fs.unlinkSync(path.join(__dirname, '../resources/' + testDir + '/dir1/dir2/abcd.ttl')) + fs.rmdirSync(path.join(__dirname, '../resources/' + testDir + '/dir1/dir2/')) + fs.rmdirSync(path.join(__dirname, '../resources/' + testDir + '/dir1/')) + fs.unlinkSync(path.join(__dirname, '../resources/' + abcFile)) + fs.unlinkSync(path.join(__dirname, '../resources/' + testDirAclFile)) + fs.unlinkSync(path.join(__dirname, '../resources/' + testDirMetaFile)) + fs.rmdirSync(path.join(__dirname, '../resources/' + testDir)) + fs.rmdirSync(path.join(__dirname, '../resources/acl-tls/')) + done() + } catch (e) { + done(e) + } + }) + }) +}) diff --git a/test/integration/auth-proxy-test.mjs b/test/integration/auth-proxy-test.js similarity index 94% rename from test/integration/auth-proxy-test.mjs rename to test/integration/auth-proxy-test.js index 2da9b0d4d..d0cbc08c6 100644 --- a/test/integration/auth-proxy-test.mjs +++ b/test/integration/auth-proxy-test.js @@ -1,144 +1,144 @@ -import { createRequire } from 'module' -import { expect } from 'chai' -import supertest from 'supertest' -import nock from 'nock' -import { fileURLToPath } from 'url' -import { dirname, join } from 'path' -import ldnode from '../../index.mjs' - -const require = createRequire(import.meta.url) -const __dirname = dirname(fileURLToPath(import.meta.url)) -const { rm } = require('../utils.mjs') - -const USER = 'https://ruben.verborgh.org/profile/#me' - -describe('Auth Proxy', () => { - describe('A Solid server with the authProxy option', () => { - let server - before(() => { - // Set up test back-end server - nock('http://server-a.org').persist() - .get(/./).reply(200, function () { return this.req.headers }) - .options(/./).reply(200) - .post(/./).reply(200) - - // Set up Solid server - server = ldnode({ - root: join(__dirname, '../resources/auth-proxy'), - configPath: join(__dirname, '../resources/config'), - authProxy: { - '/server/a': 'http://server-a.org' - }, - forceUser: USER - }) - }) - - after(() => { - // Release back-end server - nock.cleanAll() - // Remove created index files - rm('index.html') - rm('index.html.acl') - }) - - // Skipped tests due to not supported deep acl:accessTo #963 - describe.skip('responding to /server/a', () => { - let response - before(() => - supertest(server).get('/server/a/') - .then(res => { response = res }) - ) - - it('sets the User header on the proxy request', () => { - expect(response.body).to.have.property('user', USER) - }) - }) - - describe('responding to GET', () => { - describe.skip('for a path with read permissions', () => { - let response - before(() => - supertest(server).get('/server/a/r') - .then(res => { response = res }) - ) - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('for a path without read permissions', () => { - let response - before(() => - supertest(server).get('/server/a/wc') - .then(res => { response = res }) - ) - - it('returns status code 403', () => { - expect(response.statusCode).to.equal(403) - }) - }) - }) - - describe('responding to OPTIONS', () => { - describe.skip('for a path with read permissions', () => { - let response - before(() => - supertest(server).options('/server/a/r') - .then(res => { response = res }) - ) - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('for a path without read permissions', () => { - let response - before(() => - supertest(server).options('/server/a/wc') - .then(res => { response = res }) - ) - - it('returns status code 403', () => { - expect(response.statusCode).to.equal(403) - }) - }) - }) - - describe('responding to POST', () => { - describe.skip('for a path with read and write permissions', () => { - let response - before(() => - supertest(server).post('/server/a/rw') - .then(res => { response = res }) - ) - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('for a path without read permissions', () => { - let response - before(() => - supertest(server).post('/server/a/w') - .then(res => { response = res }) - ) - - it('returns status code 403', () => { - expect(response.statusCode).to.equal(403) - }) - }) - - describe('for a path without write permissions', () => { - let response - before(() => - supertest(server).post('/server/a/r') - .then(res => { response = res }) - ) - - it('returns status code 403', () => { - expect(response.statusCode).to.equal(403) - }) - }) - }) - }) -}) +import { createRequire } from 'module' +import { expect } from 'chai' +import supertest from 'supertest' +import nock from 'nock' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import ldnode from '../../index.js' + +const require = createRequire(import.meta.url) +const __dirname = dirname(fileURLToPath(import.meta.url)) +const { rm } = require('../utils.js') + +const USER = 'https://ruben.verborgh.org/profile/#me' + +describe('Auth Proxy', () => { + describe('A Solid server with the authProxy option', () => { + let server + before(() => { + // Set up test back-end server + nock('http://server-a.org').persist() + .get(/./).reply(200, function () { return this.req.headers }) + .options(/./).reply(200) + .post(/./).reply(200) + + // Set up Solid server + server = ldnode({ + root: join(__dirname, '../resources/auth-proxy'), + configPath: join(__dirname, '../resources/config'), + authProxy: { + '/server/a': 'http://server-a.org' + }, + forceUser: USER + }) + }) + + after(() => { + // Release back-end server + nock.cleanAll() + // Remove created index files + rm('index.html') + rm('index.html.acl') + }) + + // Skipped tests due to not supported deep acl:accessTo #963 + describe.skip('responding to /server/a', () => { + let response + before(() => + supertest(server).get('/server/a/') + .then(res => { response = res }) + ) + + it('sets the User header on the proxy request', () => { + expect(response.body).to.have.property('user', USER) + }) + }) + + describe('responding to GET', () => { + describe.skip('for a path with read permissions', () => { + let response + before(() => + supertest(server).get('/server/a/r') + .then(res => { response = res }) + ) + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('for a path without read permissions', () => { + let response + before(() => + supertest(server).get('/server/a/wc') + .then(res => { response = res }) + ) + + it('returns status code 403', () => { + expect(response.statusCode).to.equal(403) + }) + }) + }) + + describe('responding to OPTIONS', () => { + describe.skip('for a path with read permissions', () => { + let response + before(() => + supertest(server).options('/server/a/r') + .then(res => { response = res }) + ) + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('for a path without read permissions', () => { + let response + before(() => + supertest(server).options('/server/a/wc') + .then(res => { response = res }) + ) + + it('returns status code 403', () => { + expect(response.statusCode).to.equal(403) + }) + }) + }) + + describe('responding to POST', () => { + describe.skip('for a path with read and write permissions', () => { + let response + before(() => + supertest(server).post('/server/a/rw') + .then(res => { response = res }) + ) + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('for a path without read permissions', () => { + let response + before(() => + supertest(server).post('/server/a/w') + .then(res => { response = res }) + ) + + it('returns status code 403', () => { + expect(response.statusCode).to.equal(403) + }) + }) + + describe('for a path without write permissions', () => { + let response + before(() => + supertest(server).post('/server/a/r') + .then(res => { response = res }) + ) + + it('returns status code 403', () => { + expect(response.statusCode).to.equal(403) + }) + }) + }) + }) +}) diff --git a/test/integration/authentication-oidc-test.mjs b/test/integration/authentication-oidc-test.js similarity index 96% rename from test/integration/authentication-oidc-test.mjs rename to test/integration/authentication-oidc-test.js index 1c6097544..b836639ff 100644 --- a/test/integration/authentication-oidc-test.mjs +++ b/test/integration/authentication-oidc-test.js @@ -1,817 +1,817 @@ -import ldnode from '../../index.mjs' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import fs from 'fs-extra' -import { UserStore } from '@solid/oidc-auth-manager' -import UserAccount from '../../lib/models/user-account.mjs' -import SolidAuthOIDC from '@solid/solid-auth-oidc' - -import localStorage from 'localstorage-memory' -import { URL, URLSearchParams } from 'whatwg-url' -import { cleanDir, cp } from '../utils.mjs' - -import supertest from 'supertest' -import chai from 'chai' -import dirtyChai from 'dirty-chai' -global.URL = URL -global.URLSearchParams = URLSearchParams -const expect = chai.expect -chai.use(dirtyChai) - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// In this test we always assume that we are Alice - -// FIXME #1502 -describe('Authentication API (OIDC)', () => { - let alice, bob - - const aliceServerUri = 'https://localhost:7000' - const aliceWebId = 'https://localhost:7000/profile/card#me' - const configPath = path.normalize(path.join(__dirname, '../resources/config')) - const aliceDbPath = path.normalize(path.join(__dirname, - '../resources/accounts-scenario/alice/db')) - const userStorePath = path.join(aliceDbPath, 'oidc/users') - const aliceUserStore = UserStore.from({ path: userStorePath, saltRounds: 1 }) - aliceUserStore.initCollections() - - const bobServerUri = 'https://localhost:7001' - const bobDbPath = path.normalize(path.join(__dirname, - '../resources/accounts-scenario/bob/db')) - - const trustedAppUri = 'https://trusted.app' - - const serverConfig = { - sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), - sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), - auth: 'oidc', - dataBrowser: false, - webid: true, - multiuser: false, - configPath, - trustedOrigins: ['https://apps.solid.invalid', 'https://trusted.app'], - saltRounds: 1 - } - - const aliceRootPath = path.normalize(path.join(__dirname, '../resources/accounts-scenario/alice')) - const bobRootPath = path.normalize(path.join(__dirname, '../resources/accounts-scenario/bob')) - let alicePod - let bobPod - - async function createPods () { - alicePod = await ldnode.createServer( - Object.assign({ - root: aliceRootPath, - serverUri: aliceServerUri, - dbPath: aliceDbPath - }, serverConfig) - ) - - bobPod = await ldnode.createServer( - Object.assign({ - root: bobRootPath, - serverUri: bobServerUri, - dbPath: bobDbPath - }, serverConfig) - ) - } - - function startServer (pod, port) { - return new Promise((resolve, reject) => { - pod.on('error', (err) => { - console.error(`Server on port ${port} error:`, err) - reject(err) - }) - - const server = pod.listen(port, () => { - console.log(`Server started on port ${port}`) - resolve() - }) - - server.on('error', (err) => { - console.error(`Server listen error on port ${port}:`, err) - reject(err) - }) - }) - } - - before(async function () { - this.timeout(60000) // 60 second timeout for server startup with OIDC initialization - - // Clean and recreate OIDC database directories to ensure fresh state - const aliceOidcPath = path.join(aliceDbPath, 'oidc') - const bobOidcPath = path.join(bobDbPath, 'oidc') - - // Remove any existing OIDC data to prevent corruption - console.log('Cleaning OIDC directories...') - fs.removeSync(aliceOidcPath) - fs.removeSync(bobOidcPath) - - // Create fresh directory structure - fs.ensureDirSync(path.join(aliceOidcPath, 'op/clients')) - fs.ensureDirSync(path.join(aliceOidcPath, 'op/tokens')) - fs.ensureDirSync(path.join(aliceOidcPath, 'op/codes')) - fs.ensureDirSync(path.join(aliceOidcPath, 'users')) - fs.ensureDirSync(path.join(aliceOidcPath, 'rp/clients')) - - fs.ensureDirSync(path.join(bobOidcPath, 'op/clients')) - fs.ensureDirSync(path.join(bobOidcPath, 'op/tokens')) - fs.ensureDirSync(path.join(bobOidcPath, 'op/codes')) - fs.ensureDirSync(path.join(bobOidcPath, 'users')) - fs.ensureDirSync(path.join(bobOidcPath, 'rp/clients')) - - await createPods() - - await Promise.all([ - startServer(alicePod, 7000), - startServer(bobPod, 7001) - ]).then(() => { - alice = supertest(aliceServerUri) - bob = supertest(bobServerUri) - }) - cp(path.join('accounts-scenario/alice', '.acl-override'), path.join('accounts-scenario/alice', '.acl')) - cp(path.join('accounts-scenario/bob', '.acl-override'), path.join('accounts-scenario/bob', '.acl')) - }) - - after(() => { - alicePod.close() - bobPod.close() - fs.removeSync(path.join(aliceDbPath, 'oidc/users')) - cleanDir(aliceRootPath) - cleanDir(bobRootPath) - }) - - describe('Login page (GET /login)', () => { - it('should load the user login form', () => { - return alice.get('/login') - .expect(200) - }) - }) - - describe('Login by Username and Password (POST /login/password)', () => { - // Logging in as alice, to alice's pod - const aliceAccount = UserAccount.from({ webId: aliceWebId }) - const alicePassword = '12345' - - beforeEach(() => { - aliceUserStore.initCollections() - - return aliceUserStore.createUser(aliceAccount, alicePassword) - .catch(console.error.bind(console)) - }) - - afterEach(() => { - fs.removeSync(path.join(aliceDbPath, 'users/users')) - }) - - describe('after performing a correct login', () => { - let response, cookie - before(done => { - aliceUserStore.initCollections() - aliceUserStore.createUser(aliceAccount, alicePassword) - alice.post('/login/password') - .type('form') - .send({ username: 'alice' }) - .send({ password: alicePassword }) - .end((err, res) => { - response = res - cookie = response.headers['set-cookie'][0] - done(err) - }) - }) - - it('should redirect to /authorize', () => { - const loginUri = response.headers.location - expect(response).to.have.property('status', 302) - expect(loginUri.startsWith(aliceServerUri + '/authorize')) - }) - - it('should set the cookie', () => { - expect(cookie).to.match(/nssidp.sid=\S{65,100}/) - }) - - it('should set the cookie with HttpOnly', () => { - expect(cookie).to.match(/HttpOnly/) - }) - - it('should set the cookie with Secure', () => { - expect(cookie).to.match(/Secure/) - }) - - describe('and performing a subsequent request', () => { - describe('without that cookie', () => { - let response - before(done => { - alice.get('/private-for-alice.txt') - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => { - expect(response).to.have.property('status', 401) - }) - }) - - describe('with that cookie and a non-matching origin', () => { - let response - before(done => { - alice.get('/private-for-owner.txt') - .set('Cookie', cookie) - .set('Origin', bobServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 403', () => { - expect(response).to.have.property('status', 403) - }) - }) - - describe('with that cookie and a non-matching origin', () => { - let response - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', cookie) - .set('Origin', bobServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 403', () => { - expect(response).to.have.property('status', 403) - }) - }) - - describe('without that cookie and a non-matching origin', () => { - let response - before(done => { - alice.get('/private-for-alice.txt') - .set('Origin', bobServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => { - expect(response).to.have.property('status', 401) - }) - }) - - describe('with that cookie but without origin', () => { - let response - before(done => { - alice.get('/') - .set('Cookie', cookie) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 200', () => { - expect(response).to.have.property('status', 200) - }) - }) - - describe('with that cookie, private resource and no origin set', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', cookie) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 200', () => expect(response).to.have.property('status', 200)) - }) - - // How Mallory might set their cookie: - describe('with malicious cookie but without origin', () => { - let response - before(done => { - const malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') - alice.get('/private-for-alice.txt') - .set('Cookie', malcookie) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => { - expect(response).to.have.property('status', 401) - }) - }) - - // Our origin is trusted by default - describe('with that cookie and our origin', () => { - let response - before(done => { - alice.get('/') - .set('Cookie', cookie) - .set('Origin', aliceServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 200', () => { - expect(response).to.have.property('status', 200) - }) - }) - - // Another origin isn't trusted by default - describe('with that cookie and our origin', () => { - let response - before(done => { - alice.get('/private-for-owner.txt') - .set('Cookie', cookie) - .set('Origin', 'https://some.other.domain.com') - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 403', () => { - expect(response).to.have.property('status', 403) - }) - }) - - // Our own origin, no agent auth - describe('without that cookie but with our origin', () => { - let response - before(done => { - alice.get('/private-for-owner.txt') - .set('Origin', aliceServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => { - expect(response).to.have.property('status', 401) - }) - }) - - // Configuration for originsAllowed - describe('with that cookie but with globally configured origin', () => { - let response - before(done => { - alice.get('/') - .set('Cookie', cookie) - .set('Origin', 'https://apps.solid.invalid') - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 200', () => { - expect(response).to.have.property('status', 200) - }) - }) - - // Configuration for originsAllowed but no auth - describe('without that cookie but with globally configured origin', () => { - let response - before(done => { - alice.get('/private-for-alice.txt') - .set('Origin', 'https://apps.solid.invalid') - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => { - expect(response).to.have.property('status', 401) - }) - }) - - // Configuration for originsAllowed with malicious cookie - describe('with malicious cookie but with globally configured origin', () => { - let response - before(done => { - const malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') - alice.get('/private-for-alice.txt') - .set('Cookie', malcookie) - .set('Origin', 'https://apps.solid.invalid') - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => { - expect(response).to.have.property('status', 401) - }) - }) - - // Not authenticated but also wrong origin, - // 403 because authenticating wouldn't help, since the Origin is wrong - describe('without that cookie and a matching origin', () => { - let response - before(done => { - alice.get('/private-for-owner.txt') - .set('Origin', bobServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => { - expect(response).to.have.property('status', 401) - }) - }) - - // Authenticated but origin not OK - describe('with that cookie and a non-matching origin', () => { - let response - before(done => { - alice.get('/private-for-owner.txt') - .set('Cookie', cookie) - .set('Origin', bobServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 403', () => { - expect(response).to.have.property('status', 403) - }) - }) - - describe('with malicious cookie and our origin', () => { - let response - before(done => { - const malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') - alice.get('/private-for-alice.txt') - .set('Cookie', malcookie) - .set('Origin', aliceServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => { - expect(response).to.have.property('status', 401) - }) - }) - - describe('with malicious cookie and a non-matching origin', () => { - let response - before(done => { - const malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') - alice.get('/private-for-owner.txt') - .set('Cookie', malcookie) - .set('Origin', bobServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => { - expect(response).to.have.property('status', 401) - }) - }) - - describe('with trusted app and no cookie', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Origin', trustedAppUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - - describe('with trusted app and malicious cookie', () => { - before(done => { - const malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') - alice.get('/private-for-alice.txt') - .set('Cookie', malcookie) - .set('Origin', trustedAppUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - - describe('with trusted app and correct cookie', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', cookie) - .set('Origin', trustedAppUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 200', () => expect(response).to.have.property('status', 200)) - }) - }) - }) - - it('should throw a 400 if no username is provided', (done) => { - alice.post('/login/password') - .type('form') - .send({ password: alicePassword }) - .expect(400, done) - }) - - it('should throw a 400 if no password is provided', (done) => { - alice.post('/login/password') - .type('form') - .send({ username: 'alice' }) - .expect(400, done) - }) - - it('should throw a 400 if user is found but no password match', (done) => { - alice.post('/login/password') - .type('form') - .send({ username: 'alice' }) - .send({ password: 'wrongpassword' }) - .expect(400, done) - }) - }) - - describe('Browser login workflow', () => { - it('401 Unauthorized asking the user to log in', (done) => { - bob.get('/shared-with-alice.txt') - .end((err, { status, text }) => { - expect(status).to.equal(401) - expect(text).to.contain('GlobalDashboard') - done(err) - }) - }) - }) - - describe('Two Pods + Web App Login Workflow', () => { - const aliceAccount = UserAccount.from({ webId: aliceWebId }) - const alicePassword = '12345' - - let auth - let authorizationUri, loginUri, authParams, callbackUri - let loginFormFields = '' - let bearerToken - let postLoginUri - let cookie - let postSharingUri - - before(function () { - this.timeout(50000) // Long timeout for OIDC initialization - - auth = new SolidAuthOIDC({ store: localStorage, window: { location: {} } }) - const appOptions = { - redirectUri: 'https://app.example.com/callback' - } - - aliceUserStore.initCollections() - - return aliceUserStore.createUser(aliceAccount, alicePassword) - .then(() => { - return auth.registerClient(aliceServerUri, appOptions) - }) - .then(registeredClient => { - auth.currentClient = registeredClient - }) - }) - - after(() => { - fs.removeSync(path.join(aliceDbPath, 'users/users')) - fs.removeSync(path.join(aliceDbPath, 'oidc/op/tokens')) - - if (auth.currentClient && auth.currentClient.registration) { - const clientId = auth.currentClient.registration.client_id - const registration = `_key_${clientId}.json` - fs.removeSync(path.join(aliceDbPath, 'oidc/op/clients', registration)) - } - }) - - // Step 1: An app makes a GET request and receives a 401 - it('should get a 401 error on a REST request to a protected resource', () => { - return fetch(bobServerUri + '/shared-with-alice.txt') - .then(res => { - expect(res.status).to.equal(401) - - expect(res.headers.get('www-authenticate')) - .to.equal(`Bearer realm="${bobServerUri}", scope="openid webid"`) - }) - }) - - // Step 2: App presents the Select Provider UI to user, determine the - // preferred provider uri (here, aliceServerUri), and constructs - // an authorization uri for that provider - it('should determine the authorization uri for a preferred provider', () => { - return auth.currentClient.createRequest({}, auth.store) - .then(authUri => { - authorizationUri = authUri - - expect(authUri.startsWith(aliceServerUri + '/authorize')).to.be.true() - }) - }) - - // Step 3: App redirects user to the authorization uri for login - it('should redirect user to /authorize and /login', () => { - return fetch(authorizationUri, { redirect: 'manual' }) - .then(res => { - // Since user is not logged in, /authorize redirects to /login - expect(res.status).to.equal(302) - - loginUri = new URL(res.headers.get('location'), aliceServerUri) - expect(loginUri.toString().startsWith(aliceServerUri + '/login')) - .to.be.true() - - authParams = loginUri.searchParams - }) - }) - - // Step 4: Pod returns a /login page with appropriate hidden form fields - it('should display the /login form', () => { - return fetch(loginUri.toString()) - .then(loginPage => { - return loginPage.text() - }) - .then(pageText => { - // Login page should contain the relevant auth params as hidden fields - - authParams.forEach((value, key) => { - const hiddenField = `` - - const fieldRegex = new RegExp(hiddenField) - - expect(pageText).to.match(fieldRegex) - - loginFormFields += `${key}=` + encodeURIComponent(value) + '&' - }) - }) - }) - - // Step 5: User submits their username & password via the /login form - it('should login via the /login form', () => { - loginFormFields += `username=${'alice'}&password=${alicePassword}` - - return fetch(aliceServerUri + '/login/password', { - method: 'POST', - body: loginFormFields, - redirect: 'manual', - headers: { - 'content-type': 'application/x-www-form-urlencoded' - }, - credentials: 'include' - }) - .then(res => { - expect(res.status).to.equal(302) - const location = res.headers.get('location') - postLoginUri = new URL(location, aliceServerUri).toString() - // Native fetch: get first set-cookie header - const setCookieHeaders = res.headers.getSetCookie ? res.headers.getSetCookie() : [res.headers.get('set-cookie')] - cookie = setCookieHeaders[0] - // Successful login gets redirected back to /authorize and then - // back to app - expect(postLoginUri.startsWith(aliceServerUri + '/sharing')) - .to.be.true() - }) - }) - - // Step 6: User shares with the app accessing certain things - it('should consent via the /sharing form', () => { - loginFormFields += '&access_mode=Read&access_mode=Write&consent=true' - - return fetch(aliceServerUri + '/sharing', { - method: 'POST', - body: loginFormFields, - redirect: 'manual', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - cookie - }, - credentials: 'include' - }) - .then(res => { - expect(res.status).to.equal(302) - const location = res.headers.get('location') - postSharingUri = new URL(location, aliceServerUri).toString() - - // cookie = res.headers.get('set-cookie') - - // Successful login gets redirected back to /authorize and then - // back to app - expect(postSharingUri.startsWith(aliceServerUri + '/authorize')) - .to.be.true() - return fetch(postSharingUri, { redirect: 'manual', headers: { cookie } }) - }) - .then(res => { - // User gets redirected back to original app - expect(res.status).to.equal(302) - const location = res.headers.get('location') - callbackUri = location.startsWith('http') ? location : new URL(location, aliceServerUri).toString() - - expect(callbackUri.startsWith('https://app.example.com#')) - }) - }) - - // Step 7: Web App extracts tokens from the uri hash fragment, uses - // them to access protected resource - it('should use id token from the callback uri to access shared resource (no origin)', () => { - auth.window.location.href = callbackUri - - const protectedResourcePath = bobServerUri + '/shared-with-alice.txt' - - return auth.initUserFromResponse(auth.currentClient) - .then(webId => { - expect(webId).to.equal(aliceWebId) - - return auth.issuePoPTokenFor(bobServerUri, auth.session) - }) - .then(popToken => { - bearerToken = popToken - - return fetch(protectedResourcePath, { - headers: { - Authorization: 'Bearer ' + bearerToken - } - }) - }) - .then(res => { - expect(res.status).to.equal(200) - - return res.text() - }) - .then(contents => { - expect(contents).to.equal('protected contents\n') - }) - }) - - it('should use id token from the callback uri to access shared resource (untrusted origin)', () => { - auth.window.location.href = callbackUri - - const protectedResourcePath = bobServerUri + '/shared-with-alice.txt' - - return auth.initUserFromResponse(auth.currentClient) - .then(webId => { - expect(webId).to.equal(aliceWebId) - - return auth.issuePoPTokenFor(bobServerUri, auth.session) - }) - .then(popToken => { - bearerToken = popToken - - return fetch(protectedResourcePath, { - headers: { - Authorization: 'Bearer ' + bearerToken, - Origin: 'https://untrusted.example.com' // shouldn't be allowed if strictOrigin is set to true - } - }) - }) - .then(res => { - expect(res.status).to.equal(403) - }) - }) - - it('should not be able to reuse the bearer token for bob server on another server', () => { - const privateAliceResourcePath = aliceServerUri + '/private-for-alice.txt' - - return fetch(privateAliceResourcePath, { - headers: { - // This is Alice's bearer token with her own Web ID - Authorization: 'Bearer ' + bearerToken - } - }) - .then(res => { - // It will get rejected; it was issued for Bob's server only - expect(res.status).to.equal(403) - }) - }) - }) - - describe('Post-logout page (GET /goodbye)', () => { - it('should load the post-logout page', () => { - return alice.get('/goodbye') - .expect(200) - }) - }) -}) +import ldnode from '../../index.js' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import fs from 'fs-extra' +import { UserStore } from '@solid/oidc-auth-manager' +import UserAccount from '../../lib/models/user-account.js' +import SolidAuthOIDC from '@solid/solid-auth-oidc' + +import localStorage from 'localstorage-memory' +import { URL, URLSearchParams } from 'whatwg-url' +import { cleanDir, cp } from '../utils.js' + +import supertest from 'supertest' +import chai from 'chai' +import dirtyChai from 'dirty-chai' +global.URL = URL +global.URLSearchParams = URLSearchParams +const expect = chai.expect +chai.use(dirtyChai) + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// In this test we always assume that we are Alice + +// FIXME #1502 +describe('Authentication API (OIDC)', () => { + let alice, bob + + const aliceServerUri = 'https://localhost:7000' + const aliceWebId = 'https://localhost:7000/profile/card#me' + const configPath = path.normalize(path.join(__dirname, '../resources/config')) + const aliceDbPath = path.normalize(path.join(__dirname, + '../resources/accounts-scenario/alice/db')) + const userStorePath = path.join(aliceDbPath, 'oidc/users') + const aliceUserStore = UserStore.from({ path: userStorePath, saltRounds: 1 }) + aliceUserStore.initCollections() + + const bobServerUri = 'https://localhost:7001' + const bobDbPath = path.normalize(path.join(__dirname, + '../resources/accounts-scenario/bob/db')) + + const trustedAppUri = 'https://trusted.app' + + const serverConfig = { + sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), + sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), + auth: 'oidc', + dataBrowser: false, + webid: true, + multiuser: false, + configPath, + trustedOrigins: ['https://apps.solid.invalid', 'https://trusted.app'], + saltRounds: 1 + } + + const aliceRootPath = path.normalize(path.join(__dirname, '../resources/accounts-scenario/alice')) + const bobRootPath = path.normalize(path.join(__dirname, '../resources/accounts-scenario/bob')) + let alicePod + let bobPod + + async function createPods () { + alicePod = await ldnode.createServer( + Object.assign({ + root: aliceRootPath, + serverUri: aliceServerUri, + dbPath: aliceDbPath + }, serverConfig) + ) + + bobPod = await ldnode.createServer( + Object.assign({ + root: bobRootPath, + serverUri: bobServerUri, + dbPath: bobDbPath + }, serverConfig) + ) + } + + function startServer (pod, port) { + return new Promise((resolve, reject) => { + pod.on('error', (err) => { + console.error(`Server on port ${port} error:`, err) + reject(err) + }) + + const server = pod.listen(port, () => { + console.log(`Server started on port ${port}`) + resolve() + }) + + server.on('error', (err) => { + console.error(`Server listen error on port ${port}:`, err) + reject(err) + }) + }) + } + + before(async function () { + this.timeout(60000) // 60 second timeout for server startup with OIDC initialization + + // Clean and recreate OIDC database directories to ensure fresh state + const aliceOidcPath = path.join(aliceDbPath, 'oidc') + const bobOidcPath = path.join(bobDbPath, 'oidc') + + // Remove any existing OIDC data to prevent corruption + console.log('Cleaning OIDC directories...') + fs.removeSync(aliceOidcPath) + fs.removeSync(bobOidcPath) + + // Create fresh directory structure + fs.ensureDirSync(path.join(aliceOidcPath, 'op/clients')) + fs.ensureDirSync(path.join(aliceOidcPath, 'op/tokens')) + fs.ensureDirSync(path.join(aliceOidcPath, 'op/codes')) + fs.ensureDirSync(path.join(aliceOidcPath, 'users')) + fs.ensureDirSync(path.join(aliceOidcPath, 'rp/clients')) + + fs.ensureDirSync(path.join(bobOidcPath, 'op/clients')) + fs.ensureDirSync(path.join(bobOidcPath, 'op/tokens')) + fs.ensureDirSync(path.join(bobOidcPath, 'op/codes')) + fs.ensureDirSync(path.join(bobOidcPath, 'users')) + fs.ensureDirSync(path.join(bobOidcPath, 'rp/clients')) + + await createPods() + + await Promise.all([ + startServer(alicePod, 7000), + startServer(bobPod, 7001) + ]).then(() => { + alice = supertest(aliceServerUri) + bob = supertest(bobServerUri) + }) + cp(path.join('accounts-scenario/alice', '.acl-override'), path.join('accounts-scenario/alice', '.acl')) + cp(path.join('accounts-scenario/bob', '.acl-override'), path.join('accounts-scenario/bob', '.acl')) + }) + + after(() => { + alicePod.close() + bobPod.close() + fs.removeSync(path.join(aliceDbPath, 'oidc/users')) + cleanDir(aliceRootPath) + cleanDir(bobRootPath) + }) + + describe('Login page (GET /login)', () => { + it('should load the user login form', () => { + return alice.get('/login') + .expect(200) + }) + }) + + describe('Login by Username and Password (POST /login/password)', () => { + // Logging in as alice, to alice's pod + const aliceAccount = UserAccount.from({ webId: aliceWebId }) + const alicePassword = '12345' + + beforeEach(() => { + aliceUserStore.initCollections() + + return aliceUserStore.createUser(aliceAccount, alicePassword) + .catch(console.error.bind(console)) + }) + + afterEach(() => { + fs.removeSync(path.join(aliceDbPath, 'users/users')) + }) + + describe('after performing a correct login', () => { + let response, cookie + before(done => { + aliceUserStore.initCollections() + aliceUserStore.createUser(aliceAccount, alicePassword) + alice.post('/login/password') + .type('form') + .send({ username: 'alice' }) + .send({ password: alicePassword }) + .end((err, res) => { + response = res + cookie = response.headers['set-cookie'][0] + done(err) + }) + }) + + it('should redirect to /authorize', () => { + const loginUri = response.headers.location + expect(response).to.have.property('status', 302) + expect(loginUri.startsWith(aliceServerUri + '/authorize')) + }) + + it('should set the cookie', () => { + expect(cookie).to.match(/nssidp.sid=\S{65,100}/) + }) + + it('should set the cookie with HttpOnly', () => { + expect(cookie).to.match(/HttpOnly/) + }) + + it('should set the cookie with Secure', () => { + expect(cookie).to.match(/Secure/) + }) + + describe('and performing a subsequent request', () => { + describe('without that cookie', () => { + let response + before(done => { + alice.get('/private-for-alice.txt') + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + describe('with that cookie and a non-matching origin', () => { + let response + before(done => { + alice.get('/private-for-owner.txt') + .set('Cookie', cookie) + .set('Origin', bobServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 403', () => { + expect(response).to.have.property('status', 403) + }) + }) + + describe('with that cookie and a non-matching origin', () => { + let response + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', cookie) + .set('Origin', bobServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 403', () => { + expect(response).to.have.property('status', 403) + }) + }) + + describe('without that cookie and a non-matching origin', () => { + let response + before(done => { + alice.get('/private-for-alice.txt') + .set('Origin', bobServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + describe('with that cookie but without origin', () => { + let response + before(done => { + alice.get('/') + .set('Cookie', cookie) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 200', () => { + expect(response).to.have.property('status', 200) + }) + }) + + describe('with that cookie, private resource and no origin set', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', cookie) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 200', () => expect(response).to.have.property('status', 200)) + }) + + // How Mallory might set their cookie: + describe('with malicious cookie but without origin', () => { + let response + before(done => { + const malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') + alice.get('/private-for-alice.txt') + .set('Cookie', malcookie) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + // Our origin is trusted by default + describe('with that cookie and our origin', () => { + let response + before(done => { + alice.get('/') + .set('Cookie', cookie) + .set('Origin', aliceServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 200', () => { + expect(response).to.have.property('status', 200) + }) + }) + + // Another origin isn't trusted by default + describe('with that cookie and our origin', () => { + let response + before(done => { + alice.get('/private-for-owner.txt') + .set('Cookie', cookie) + .set('Origin', 'https://some.other.domain.com') + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 403', () => { + expect(response).to.have.property('status', 403) + }) + }) + + // Our own origin, no agent auth + describe('without that cookie but with our origin', () => { + let response + before(done => { + alice.get('/private-for-owner.txt') + .set('Origin', aliceServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + // Configuration for originsAllowed + describe('with that cookie but with globally configured origin', () => { + let response + before(done => { + alice.get('/') + .set('Cookie', cookie) + .set('Origin', 'https://apps.solid.invalid') + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 200', () => { + expect(response).to.have.property('status', 200) + }) + }) + + // Configuration for originsAllowed but no auth + describe('without that cookie but with globally configured origin', () => { + let response + before(done => { + alice.get('/private-for-alice.txt') + .set('Origin', 'https://apps.solid.invalid') + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + // Configuration for originsAllowed with malicious cookie + describe('with malicious cookie but with globally configured origin', () => { + let response + before(done => { + const malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') + alice.get('/private-for-alice.txt') + .set('Cookie', malcookie) + .set('Origin', 'https://apps.solid.invalid') + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + // Not authenticated but also wrong origin, + // 403 because authenticating wouldn't help, since the Origin is wrong + describe('without that cookie and a matching origin', () => { + let response + before(done => { + alice.get('/private-for-owner.txt') + .set('Origin', bobServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + // Authenticated but origin not OK + describe('with that cookie and a non-matching origin', () => { + let response + before(done => { + alice.get('/private-for-owner.txt') + .set('Cookie', cookie) + .set('Origin', bobServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 403', () => { + expect(response).to.have.property('status', 403) + }) + }) + + describe('with malicious cookie and our origin', () => { + let response + before(done => { + const malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') + alice.get('/private-for-alice.txt') + .set('Cookie', malcookie) + .set('Origin', aliceServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + describe('with malicious cookie and a non-matching origin', () => { + let response + before(done => { + const malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') + alice.get('/private-for-owner.txt') + .set('Cookie', malcookie) + .set('Origin', bobServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => { + expect(response).to.have.property('status', 401) + }) + }) + + describe('with trusted app and no cookie', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Origin', trustedAppUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + + describe('with trusted app and malicious cookie', () => { + before(done => { + const malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') + alice.get('/private-for-alice.txt') + .set('Cookie', malcookie) + .set('Origin', trustedAppUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + + describe('with trusted app and correct cookie', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', cookie) + .set('Origin', trustedAppUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 200', () => expect(response).to.have.property('status', 200)) + }) + }) + }) + + it('should throw a 400 if no username is provided', (done) => { + alice.post('/login/password') + .type('form') + .send({ password: alicePassword }) + .expect(400, done) + }) + + it('should throw a 400 if no password is provided', (done) => { + alice.post('/login/password') + .type('form') + .send({ username: 'alice' }) + .expect(400, done) + }) + + it('should throw a 400 if user is found but no password match', (done) => { + alice.post('/login/password') + .type('form') + .send({ username: 'alice' }) + .send({ password: 'wrongpassword' }) + .expect(400, done) + }) + }) + + describe('Browser login workflow', () => { + it('401 Unauthorized asking the user to log in', (done) => { + bob.get('/shared-with-alice.txt') + .end((err, { status, text }) => { + expect(status).to.equal(401) + expect(text).to.contain('GlobalDashboard') + done(err) + }) + }) + }) + + describe('Two Pods + Web App Login Workflow', () => { + const aliceAccount = UserAccount.from({ webId: aliceWebId }) + const alicePassword = '12345' + + let auth + let authorizationUri, loginUri, authParams, callbackUri + let loginFormFields = '' + let bearerToken + let postLoginUri + let cookie + let postSharingUri + + before(function () { + this.timeout(50000) // Long timeout for OIDC initialization + + auth = new SolidAuthOIDC({ store: localStorage, window: { location: {} } }) + const appOptions = { + redirectUri: 'https://app.example.com/callback' + } + + aliceUserStore.initCollections() + + return aliceUserStore.createUser(aliceAccount, alicePassword) + .then(() => { + return auth.registerClient(aliceServerUri, appOptions) + }) + .then(registeredClient => { + auth.currentClient = registeredClient + }) + }) + + after(() => { + fs.removeSync(path.join(aliceDbPath, 'users/users')) + fs.removeSync(path.join(aliceDbPath, 'oidc/op/tokens')) + + if (auth.currentClient && auth.currentClient.registration) { + const clientId = auth.currentClient.registration.client_id + const registration = `_key_${clientId}.json` + fs.removeSync(path.join(aliceDbPath, 'oidc/op/clients', registration)) + } + }) + + // Step 1: An app makes a GET request and receives a 401 + it('should get a 401 error on a REST request to a protected resource', () => { + return fetch(bobServerUri + '/shared-with-alice.txt') + .then(res => { + expect(res.status).to.equal(401) + + expect(res.headers.get('www-authenticate')) + .to.equal(`Bearer realm="${bobServerUri}", scope="openid webid"`) + }) + }) + + // Step 2: App presents the Select Provider UI to user, determine the + // preferred provider uri (here, aliceServerUri), and constructs + // an authorization uri for that provider + it('should determine the authorization uri for a preferred provider', () => { + return auth.currentClient.createRequest({}, auth.store) + .then(authUri => { + authorizationUri = authUri + + expect(authUri.startsWith(aliceServerUri + '/authorize')).to.be.true() + }) + }) + + // Step 3: App redirects user to the authorization uri for login + it('should redirect user to /authorize and /login', () => { + return fetch(authorizationUri, { redirect: 'manual' }) + .then(res => { + // Since user is not logged in, /authorize redirects to /login + expect(res.status).to.equal(302) + + loginUri = new URL(res.headers.get('location'), aliceServerUri) + expect(loginUri.toString().startsWith(aliceServerUri + '/login')) + .to.be.true() + + authParams = loginUri.searchParams + }) + }) + + // Step 4: Pod returns a /login page with appropriate hidden form fields + it('should display the /login form', () => { + return fetch(loginUri.toString()) + .then(loginPage => { + return loginPage.text() + }) + .then(pageText => { + // Login page should contain the relevant auth params as hidden fields + + authParams.forEach((value, key) => { + const hiddenField = `` + + const fieldRegex = new RegExp(hiddenField) + + expect(pageText).to.match(fieldRegex) + + loginFormFields += `${key}=` + encodeURIComponent(value) + '&' + }) + }) + }) + + // Step 5: User submits their username & password via the /login form + it('should login via the /login form', () => { + loginFormFields += `username=${'alice'}&password=${alicePassword}` + + return fetch(aliceServerUri + '/login/password', { + method: 'POST', + body: loginFormFields, + redirect: 'manual', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + }, + credentials: 'include' + }) + .then(res => { + expect(res.status).to.equal(302) + const location = res.headers.get('location') + postLoginUri = new URL(location, aliceServerUri).toString() + // Native fetch: get first set-cookie header + const setCookieHeaders = res.headers.getSetCookie ? res.headers.getSetCookie() : [res.headers.get('set-cookie')] + cookie = setCookieHeaders[0] + // Successful login gets redirected back to /authorize and then + // back to app + expect(postLoginUri.startsWith(aliceServerUri + '/sharing')) + .to.be.true() + }) + }) + + // Step 6: User shares with the app accessing certain things + it('should consent via the /sharing form', () => { + loginFormFields += '&access_mode=Read&access_mode=Write&consent=true' + + return fetch(aliceServerUri + '/sharing', { + method: 'POST', + body: loginFormFields, + redirect: 'manual', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + cookie + }, + credentials: 'include' + }) + .then(res => { + expect(res.status).to.equal(302) + const location = res.headers.get('location') + postSharingUri = new URL(location, aliceServerUri).toString() + + // cookie = res.headers.get('set-cookie') + + // Successful login gets redirected back to /authorize and then + // back to app + expect(postSharingUri.startsWith(aliceServerUri + '/authorize')) + .to.be.true() + return fetch(postSharingUri, { redirect: 'manual', headers: { cookie } }) + }) + .then(res => { + // User gets redirected back to original app + expect(res.status).to.equal(302) + const location = res.headers.get('location') + callbackUri = location.startsWith('http') ? location : new URL(location, aliceServerUri).toString() + + expect(callbackUri.startsWith('https://app.example.com#')) + }) + }) + + // Step 7: Web App extracts tokens from the uri hash fragment, uses + // them to access protected resource + it('should use id token from the callback uri to access shared resource (no origin)', () => { + auth.window.location.href = callbackUri + + const protectedResourcePath = bobServerUri + '/shared-with-alice.txt' + + return auth.initUserFromResponse(auth.currentClient) + .then(webId => { + expect(webId).to.equal(aliceWebId) + + return auth.issuePoPTokenFor(bobServerUri, auth.session) + }) + .then(popToken => { + bearerToken = popToken + + return fetch(protectedResourcePath, { + headers: { + Authorization: 'Bearer ' + bearerToken + } + }) + }) + .then(res => { + expect(res.status).to.equal(200) + + return res.text() + }) + .then(contents => { + expect(contents).to.equal('protected contents\n') + }) + }) + + it('should use id token from the callback uri to access shared resource (untrusted origin)', () => { + auth.window.location.href = callbackUri + + const protectedResourcePath = bobServerUri + '/shared-with-alice.txt' + + return auth.initUserFromResponse(auth.currentClient) + .then(webId => { + expect(webId).to.equal(aliceWebId) + + return auth.issuePoPTokenFor(bobServerUri, auth.session) + }) + .then(popToken => { + bearerToken = popToken + + return fetch(protectedResourcePath, { + headers: { + Authorization: 'Bearer ' + bearerToken, + Origin: 'https://untrusted.example.com' // shouldn't be allowed if strictOrigin is set to true + } + }) + }) + .then(res => { + expect(res.status).to.equal(403) + }) + }) + + it('should not be able to reuse the bearer token for bob server on another server', () => { + const privateAliceResourcePath = aliceServerUri + '/private-for-alice.txt' + + return fetch(privateAliceResourcePath, { + headers: { + // This is Alice's bearer token with her own Web ID + Authorization: 'Bearer ' + bearerToken + } + }) + .then(res => { + // It will get rejected; it was issued for Bob's server only + expect(res.status).to.equal(403) + }) + }) + }) + + describe('Post-logout page (GET /goodbye)', () => { + it('should load the post-logout page', () => { + return alice.get('/goodbye') + .expect(200) + }) + }) +}) diff --git a/test/integration/authentication-oidc-with-strict-origins-turned-off-test.mjs b/test/integration/authentication-oidc-with-strict-origins-turned-off-test.js similarity index 96% rename from test/integration/authentication-oidc-with-strict-origins-turned-off-test.mjs rename to test/integration/authentication-oidc-with-strict-origins-turned-off-test.js index daac501b7..6215b8380 100644 --- a/test/integration/authentication-oidc-with-strict-origins-turned-off-test.mjs +++ b/test/integration/authentication-oidc-with-strict-origins-turned-off-test.js @@ -1,643 +1,643 @@ -import ldnode from '../../index.mjs' -import path from 'path' -import { fileURLToPath } from 'url' -import fs from 'fs-extra' -import { UserStore } from '@solid/oidc-auth-manager' -import UserAccount from '../../lib/models/user-account.mjs' -import SolidAuthOIDC from '@solid/solid-auth-oidc' - -import localStorage from 'localstorage-memory' -import { URL, URLSearchParams } from 'whatwg-url' -import { cleanDir, cp } from '../utils.mjs' - -import supertest from 'supertest' -import chai from 'chai' -import dirtyChai from 'dirty-chai' -global.URL = URL -global.URLSearchParams = URLSearchParams -const expect = chai.expect -chai.use(dirtyChai) - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// In this test we always assume that we are Alice - -describe('Authentication API (OIDC) - With strict origins turned off', () => { - let alice, bob - - const aliceServerPort = 7010 - const aliceServerUri = `https://localhost:${aliceServerPort}` - const aliceWebId = `https://localhost:${aliceServerPort}/profile/card#me` - const configPath = path.normalize(path.join(__dirname, '../resources/config')) - const aliceDbPath = path.normalize(path.join(__dirname, '../resources/accounts-strict-origin-off/alice/db')) - const userStorePath = path.join(aliceDbPath, 'oidc/users') - const aliceUserStore = UserStore.from({ path: userStorePath, saltRounds: 1 }) - aliceUserStore.initCollections() - - const bobServerPort = 7011 - const bobServerUri = `https://localhost:${bobServerPort}` - const bobDbPath = path.normalize(path.join(__dirname, '../resources/accounts-strict-origin-off/bob/db')) - - const trustedAppUri = 'https://trusted.app' - - const serverConfig = { - sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), - sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), - auth: 'oidc', - dataBrowser: false, - webid: true, - multiuser: false, - configPath, - strictOrigin: false - } - - const aliceRootPath = path.normalize(path.join(__dirname, '../resources/accounts-strict-origin-off/alice')) - const alicePod = ldnode.createServer( - Object.assign({ - root: aliceRootPath, - serverUri: aliceServerUri, - dbPath: aliceDbPath - }, serverConfig) - ) - const bobRootPath = path.normalize(path.join(__dirname, '../resources/accounts-strict-origin-off/bob')) - const bobPod = ldnode.createServer( - Object.assign({ - root: bobRootPath, - serverUri: bobServerUri, - dbPath: bobDbPath - }, serverConfig) - ) - - function startServer (pod, port) { - return new Promise((resolve) => { - pod.listen(port, () => { resolve() }) - }) - } - - before(async () => { - await Promise.all([ - startServer(alicePod, aliceServerPort), - startServer(bobPod, bobServerPort) - ]).then(() => { - alice = supertest(aliceServerUri) - bob = supertest(bobServerUri) - }) - cp(path.join('accounts-strict-origin-off/alice', '.acl-override'), path.join('accounts-strict-origin-off/alice', '.acl')) - cp(path.join('accounts-strict-origin-off/bob', '.acl-override'), path.join('accounts-strict-origin-off/bob', '.acl')) - }) - - after(() => { - alicePod.close() - bobPod.close() - fs.removeSync(path.join(aliceDbPath, 'oidc/users')) - cleanDir(aliceRootPath) - cleanDir(bobRootPath) - }) - - describe('Login page (GET /login)', () => { - it('should load the user login form', () => alice.get('/login').expect(200)) - }) - - describe('Login by Username and Password (POST /login/password)', () => { - // Logging in as alice, to alice's pod - const aliceAccount = UserAccount.from({ webId: aliceWebId }) - const alicePassword = '12345' - - beforeEach(() => { - aliceUserStore.initCollections() - - return aliceUserStore.createUser(aliceAccount, alicePassword) - .catch(console.error.bind(console)) - }) - - afterEach(() => { - fs.removeSync(path.join(aliceDbPath, 'users/users')) - }) - - describe('after performing a correct login', () => { - let response, cookie - before(done => { - aliceUserStore.initCollections() - aliceUserStore.createUser(aliceAccount, alicePassword) - alice.post('/login/password') - .type('form') - .send({ username: 'alice' }) - .send({ password: alicePassword }) - .end((err, res) => { - response = res - cookie = response.headers['set-cookie'][0] - done(err) - }) - }) - - it('should redirect to /authorize', () => { - const loginUri = response.headers.location - expect(response).to.have.property('status', 302) - expect(loginUri.startsWith(aliceServerUri + '/authorize')) - }) - - it('should set the cookie', () => { - expect(cookie).to.match(/nssidp.sid=\S{65,100}/) - }) - - it('should set the cookie with HttpOnly', () => { - expect(cookie).to.match(/HttpOnly/) - }) - - it('should set the cookie with Secure', () => { - expect(cookie).to.match(/Secure/) - }) - - describe('and performing a subsequent request', () => { - let response - describe('without cookie', () => { - describe('and no origin set', () => { - before(done => { - alice.get('/private-for-alice.txt') - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - describe('and our origin', () => { - // Our own origin, no agent auth - before(done => { - alice.get('/private-for-alice.txt') - .set('Origin', aliceServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - describe('and trusted origin', () => { - // Configuration for originsAllowed but no auth - before(done => { - alice.get('/private-for-alice.txt') - .set('Origin', 'https://apps.solid.invalid') - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - describe('and untrusted origin', () => { - // Not authenticated but also wrong origin, - before(done => { - alice.get('/private-for-alice.txt') - .set('Origin', bobServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - describe('and trusted app', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Origin', trustedAppUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - }) - - describe('with cookie', () => { - describe('and no origin set', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', cookie) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 200', () => expect(response).to.have.property('status', 200)) - }) - describe('and our origin', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', cookie) - .set('Origin', aliceServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 200', () => expect(response).to.have.property('status', 200)) - }) - describe('and trusted origin', () => { - before(done => { - alice.get('/') - .set('Cookie', cookie) - .set('Origin', 'https://apps.solid.invalid') // TODO: Should we configure the server with that? Should it matter? - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - describe('and untrusted origin', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', cookie) - .set('Origin', bobServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - // Even if origin checking is disabled, then this should return a 401 because cookies should not be trusted cross-origin - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - - describe('and trusted app', () => { - // Trusted apps are not supported when strictOrigin check is turned off - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', cookie) - .set('Origin', trustedAppUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - }) - - describe('with malicious cookie', () => { - let malcookie - before(() => { - // How Mallory might set their cookie: - malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') - }) - describe('and no origin set', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', malcookie) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - describe('and our origin', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', malcookie) - .set('Origin', aliceServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - describe('and trusted origin', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', malcookie) - .set('Origin', 'https://apps.solid.invalid') - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - describe('and untrusted origin', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', malcookie) - .set('Origin', bobServerUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - - describe('and trusted app', () => { - before(done => { - alice.get('/private-for-alice.txt') - .set('Cookie', malcookie) - .set('Origin', trustedAppUri) - .end((err, res) => { - response = res - done(err) - }) - }) - - it('should return a 401', () => expect(response).to.have.property('status', 401)) - }) - }) - }) - }) - - it('should throw a 400 if no username is provided', (done) => { - alice.post('/login/password') - .type('form') - .send({ password: alicePassword }) - .expect(400, done) - }) - - it('should throw a 400 if no password is provided', (done) => { - alice.post('/login/password') - .type('form') - .send({ username: 'alice' }) - .expect(400, done) - }) - - it('should throw a 400 if user is found but no password match', (done) => { - alice.post('/login/password') - .type('form') - .send({ username: 'alice' }) - .send({ password: 'wrongpassword' }) - .expect(400, done) - }) - }) - - describe('Browser login workflow', () => { - it('401 Unauthorized asking the user to log in', (done) => { - bob.get('/shared-with-alice.txt', { headers: { accept: 'text/html' } }) - .end((err, { status, text }) => { - expect(status).to.equal(401) - expect(text).to.contain('GlobalDashboard') - done(err) - }) - }) - }) - - describe('Two Pods + Web App Login Workflow', () => { - const aliceAccount = UserAccount.from({ webId: aliceWebId }) - const alicePassword = '12345' - - let auth - let authorizationUri, loginUri, authParams, callbackUri - let loginFormFields = '' - let bearerToken - let cookie - let postLoginUri - - before(() => { - auth = new SolidAuthOIDC({ store: localStorage, window: { location: {} } }) - const appOptions = { - redirectUri: 'https://app.example.com/callback' - } - - aliceUserStore.initCollections() - - return aliceUserStore.createUser(aliceAccount, alicePassword) - .then(() => { - return auth.registerClient(aliceServerUri, appOptions) - }) - .then(registeredClient => { - auth.currentClient = registeredClient - }) - }) - - after(() => { - fs.removeSync(path.join(aliceDbPath, 'users/users')) - fs.removeSync(path.join(aliceDbPath, 'oidc/op/tokens')) - - const clientId = auth.currentClient.registration.client_id - const registration = `_key_${clientId}.json` - fs.removeSync(path.join(aliceDbPath, 'oidc/op/clients', registration)) - }) - - // Step 1: An app makes a GET request and receives a 401 - it('should get a 401 error on a REST request to a protected resource', () => { - return fetch(bobServerUri + '/shared-with-alice.txt') - .then(res => { - expect(res.status).to.equal(401) - - expect(res.headers.get('www-authenticate')) - .to.equal(`Bearer realm="${bobServerUri}", scope="openid webid"`) - }) - }) - - // Step 2: App presents the Select Provider UI to user, determine the - // preferred provider uri (here, aliceServerUri), and constructs - // an authorization uri for that provider - it('should determine the authorization uri for a preferred provider', () => { - return auth.currentClient.createRequest({}, auth.store) - .then(authUri => { - authorizationUri = authUri - - expect(authUri.startsWith(aliceServerUri + '/authorize')).to.be.true() - }) - }) - - // Step 3: App redirects user to the authorization uri for login - it('should redirect user to /authorize and /login', () => { - return fetch(authorizationUri, { redirect: 'manual' }) - .then(res => { - // Since user is not logged in, /authorize redirects to /login - expect(res.status).to.equal(302) - - loginUri = new URL(res.headers.get('location'), aliceServerUri) - expect(loginUri.toString().startsWith(aliceServerUri + '/login')) - .to.be.true() - - authParams = loginUri.searchParams - }) - }) - - // Step 4: Pod returns a /login page with appropriate hidden form fields - it('should display the /login form', () => { - return fetch(loginUri.toString()) - .then(loginPage => { - return loginPage.text() - }) - .then(pageText => { - // Login page should contain the relevant auth params as hidden fields - - authParams.forEach((value, key) => { - const hiddenField = `` - - const fieldRegex = new RegExp(hiddenField) - - expect(pageText).to.match(fieldRegex) - - loginFormFields += `${key}=` + encodeURIComponent(value) + '&' - }) - }) - }) - - // Step 5: User submits their username & password via the /login form - it('should login via the /login form', () => { - loginFormFields += `username=${'alice'}&password=${alicePassword}` - - return fetch(aliceServerUri + '/login/password', { - method: 'POST', - body: loginFormFields, - redirect: 'manual', - headers: { - 'content-type': 'application/x-www-form-urlencoded' - }, - credentials: 'include' - }) - .then(res => { - expect(res.status).to.equal(302) - const location = res.headers.get('location') - postLoginUri = new URL(location, aliceServerUri).toString() - // Native fetch: get first set-cookie header - const setCookieHeaders = res.headers.getSetCookie ? res.headers.getSetCookie() : [res.headers.get('set-cookie')] - cookie = setCookieHeaders[0] - - // Successful login gets redirected back to /authorize and then - // back to app - expect(postLoginUri.startsWith(aliceServerUri + '/sharing')) - .to.be.true() - }) - }) - - // Step 6: User consents to the app accessing certain things - it('should consent via the /sharing form', () => { - loginFormFields += '&access_mode=Read&access_mode=Write&consent=true' - - return fetch(aliceServerUri + '/sharing', { - method: 'POST', - body: loginFormFields, - redirect: 'manual', - headers: { - 'content-type': 'application/x-www-form-urlencoded', - cookie - }, - credentials: 'include' - }) - .then(res => { - expect(res.status).to.equal(302) - const location = res.headers.get('location') - const postSharingUri = new URL(location, aliceServerUri).toString() - const setCookieHeaders = res.headers.getSetCookie ? res.headers.getSetCookie() : [res.headers.get('set-cookie')] - const cookieFromSharing = setCookieHeaders[0] || cookie - - // Successful login gets redirected back to /authorize and then - // back to app - expect(postSharingUri.startsWith(aliceServerUri + '/authorize')) - .to.be.true() - - return fetch(postSharingUri, { redirect: 'manual', headers: { cookie: cookieFromSharing } }) - }) - .then(res => { - // User gets redirected back to original app - expect(res.status).to.equal(302) - const location = res.headers.get('location') - callbackUri = location.startsWith('http') ? location : new URL(location, aliceServerUri).toString() - expect(callbackUri.startsWith('https://app.example.com#')) - }) - }) - - // Step 6: Web App extracts tokens from the uri hash fragment, uses - // them to access protected resource - it('should use id token from the callback uri to access shared resource (no origin)', () => { - auth.window.location.href = callbackUri - - const protectedResourcePath = bobServerUri + '/shared-with-alice.txt' - - return auth.initUserFromResponse(auth.currentClient) - .then(webId => { - expect(webId).to.equal(aliceWebId) - - return auth.issuePoPTokenFor(bobServerUri, auth.session) - }) - .then(popToken => { - bearerToken = popToken - - return fetch(protectedResourcePath, { - headers: { - Authorization: 'Bearer ' + bearerToken - } - }) - }) - .then(res => { - expect(res.status).to.equal(200) - - return res.text() - }) - .then(contents => { - expect(contents).to.equal('protected contents\n') - }) - }) - it('should use id token from the callback uri to access shared resource (untrusted origin)', () => { - auth.window.location.href = callbackUri - - const protectedResourcePath = bobServerUri + '/shared-with-alice.txt' - - return auth.initUserFromResponse(auth.currentClient) - .then(webId => { - expect(webId).to.equal(aliceWebId) - - return auth.issuePoPTokenFor(bobServerUri, auth.session) - }) - .then(popToken => { - bearerToken = popToken - - return fetch(protectedResourcePath, { - headers: { - Authorization: 'Bearer ' + bearerToken, - Origin: 'https://untrusted.example.com' // shouldn't matter if strictOrigin is set to false - } - }) - }) - .then(res => { - expect(res.status).to.equal(200) - - return res.text() - }) - .then(contents => { - expect(contents).to.equal('protected contents\n') - }) - }) - - it('should not be able to reuse the bearer token for bob server on another server', () => { - const privateAliceResourcePath = aliceServerUri + '/private-for-alice.txt' - - return fetch(privateAliceResourcePath, { - headers: { - // This is Alice's bearer token with her own Web ID - Authorization: 'Bearer ' + bearerToken - } - }) - .then(res => { - // It will get rejected; it was issued for Bob's server only - expect(res.status).to.equal(403) - }) - }) - }) - - describe('Post-logout page (GET /goodbye)', () => { - it('should load the post-logout page', () => { - return alice.get('/goodbye') - .expect(200) - }) - }) -}) +import ldnode from '../../index.js' +import path from 'path' +import { fileURLToPath } from 'url' +import fs from 'fs-extra' +import { UserStore } from '@solid/oidc-auth-manager' +import UserAccount from '../../lib/models/user-account.js' +import SolidAuthOIDC from '@solid/solid-auth-oidc' + +import localStorage from 'localstorage-memory' +import { URL, URLSearchParams } from 'whatwg-url' +import { cleanDir, cp } from '../utils.js' + +import supertest from 'supertest' +import chai from 'chai' +import dirtyChai from 'dirty-chai' +global.URL = URL +global.URLSearchParams = URLSearchParams +const expect = chai.expect +chai.use(dirtyChai) + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// In this test we always assume that we are Alice + +describe('Authentication API (OIDC) - With strict origins turned off', () => { + let alice, bob + + const aliceServerPort = 7010 + const aliceServerUri = `https://localhost:${aliceServerPort}` + const aliceWebId = `https://localhost:${aliceServerPort}/profile/card#me` + const configPath = path.normalize(path.join(__dirname, '../resources/config')) + const aliceDbPath = path.normalize(path.join(__dirname, '../resources/accounts-strict-origin-off/alice/db')) + const userStorePath = path.join(aliceDbPath, 'oidc/users') + const aliceUserStore = UserStore.from({ path: userStorePath, saltRounds: 1 }) + aliceUserStore.initCollections() + + const bobServerPort = 7011 + const bobServerUri = `https://localhost:${bobServerPort}` + const bobDbPath = path.normalize(path.join(__dirname, '../resources/accounts-strict-origin-off/bob/db')) + + const trustedAppUri = 'https://trusted.app' + + const serverConfig = { + sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), + sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), + auth: 'oidc', + dataBrowser: false, + webid: true, + multiuser: false, + configPath, + strictOrigin: false + } + + const aliceRootPath = path.normalize(path.join(__dirname, '../resources/accounts-strict-origin-off/alice')) + const alicePod = ldnode.createServer( + Object.assign({ + root: aliceRootPath, + serverUri: aliceServerUri, + dbPath: aliceDbPath + }, serverConfig) + ) + const bobRootPath = path.normalize(path.join(__dirname, '../resources/accounts-strict-origin-off/bob')) + const bobPod = ldnode.createServer( + Object.assign({ + root: bobRootPath, + serverUri: bobServerUri, + dbPath: bobDbPath + }, serverConfig) + ) + + function startServer (pod, port) { + return new Promise((resolve) => { + pod.listen(port, () => { resolve() }) + }) + } + + before(async () => { + await Promise.all([ + startServer(alicePod, aliceServerPort), + startServer(bobPod, bobServerPort) + ]).then(() => { + alice = supertest(aliceServerUri) + bob = supertest(bobServerUri) + }) + cp(path.join('accounts-strict-origin-off/alice', '.acl-override'), path.join('accounts-strict-origin-off/alice', '.acl')) + cp(path.join('accounts-strict-origin-off/bob', '.acl-override'), path.join('accounts-strict-origin-off/bob', '.acl')) + }) + + after(() => { + alicePod.close() + bobPod.close() + fs.removeSync(path.join(aliceDbPath, 'oidc/users')) + cleanDir(aliceRootPath) + cleanDir(bobRootPath) + }) + + describe('Login page (GET /login)', () => { + it('should load the user login form', () => alice.get('/login').expect(200)) + }) + + describe('Login by Username and Password (POST /login/password)', () => { + // Logging in as alice, to alice's pod + const aliceAccount = UserAccount.from({ webId: aliceWebId }) + const alicePassword = '12345' + + beforeEach(() => { + aliceUserStore.initCollections() + + return aliceUserStore.createUser(aliceAccount, alicePassword) + .catch(console.error.bind(console)) + }) + + afterEach(() => { + fs.removeSync(path.join(aliceDbPath, 'users/users')) + }) + + describe('after performing a correct login', () => { + let response, cookie + before(done => { + aliceUserStore.initCollections() + aliceUserStore.createUser(aliceAccount, alicePassword) + alice.post('/login/password') + .type('form') + .send({ username: 'alice' }) + .send({ password: alicePassword }) + .end((err, res) => { + response = res + cookie = response.headers['set-cookie'][0] + done(err) + }) + }) + + it('should redirect to /authorize', () => { + const loginUri = response.headers.location + expect(response).to.have.property('status', 302) + expect(loginUri.startsWith(aliceServerUri + '/authorize')) + }) + + it('should set the cookie', () => { + expect(cookie).to.match(/nssidp.sid=\S{65,100}/) + }) + + it('should set the cookie with HttpOnly', () => { + expect(cookie).to.match(/HttpOnly/) + }) + + it('should set the cookie with Secure', () => { + expect(cookie).to.match(/Secure/) + }) + + describe('and performing a subsequent request', () => { + let response + describe('without cookie', () => { + describe('and no origin set', () => { + before(done => { + alice.get('/private-for-alice.txt') + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + describe('and our origin', () => { + // Our own origin, no agent auth + before(done => { + alice.get('/private-for-alice.txt') + .set('Origin', aliceServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + describe('and trusted origin', () => { + // Configuration for originsAllowed but no auth + before(done => { + alice.get('/private-for-alice.txt') + .set('Origin', 'https://apps.solid.invalid') + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + describe('and untrusted origin', () => { + // Not authenticated but also wrong origin, + before(done => { + alice.get('/private-for-alice.txt') + .set('Origin', bobServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + describe('and trusted app', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Origin', trustedAppUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + }) + + describe('with cookie', () => { + describe('and no origin set', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', cookie) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 200', () => expect(response).to.have.property('status', 200)) + }) + describe('and our origin', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', cookie) + .set('Origin', aliceServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 200', () => expect(response).to.have.property('status', 200)) + }) + describe('and trusted origin', () => { + before(done => { + alice.get('/') + .set('Cookie', cookie) + .set('Origin', 'https://apps.solid.invalid') // TODO: Should we configure the server with that? Should it matter? + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + describe('and untrusted origin', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', cookie) + .set('Origin', bobServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + // Even if origin checking is disabled, then this should return a 401 because cookies should not be trusted cross-origin + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + + describe('and trusted app', () => { + // Trusted apps are not supported when strictOrigin check is turned off + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', cookie) + .set('Origin', trustedAppUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + }) + + describe('with malicious cookie', () => { + let malcookie + before(() => { + // How Mallory might set their cookie: + malcookie = cookie.replace(/nssidp\.sid=(\S+)/, 'nssidp.sid=l33th4x0rzp0wn4g3;') + }) + describe('and no origin set', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', malcookie) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + describe('and our origin', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', malcookie) + .set('Origin', aliceServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + describe('and trusted origin', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', malcookie) + .set('Origin', 'https://apps.solid.invalid') + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + describe('and untrusted origin', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', malcookie) + .set('Origin', bobServerUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + + describe('and trusted app', () => { + before(done => { + alice.get('/private-for-alice.txt') + .set('Cookie', malcookie) + .set('Origin', trustedAppUri) + .end((err, res) => { + response = res + done(err) + }) + }) + + it('should return a 401', () => expect(response).to.have.property('status', 401)) + }) + }) + }) + }) + + it('should throw a 400 if no username is provided', (done) => { + alice.post('/login/password') + .type('form') + .send({ password: alicePassword }) + .expect(400, done) + }) + + it('should throw a 400 if no password is provided', (done) => { + alice.post('/login/password') + .type('form') + .send({ username: 'alice' }) + .expect(400, done) + }) + + it('should throw a 400 if user is found but no password match', (done) => { + alice.post('/login/password') + .type('form') + .send({ username: 'alice' }) + .send({ password: 'wrongpassword' }) + .expect(400, done) + }) + }) + + describe('Browser login workflow', () => { + it('401 Unauthorized asking the user to log in', (done) => { + bob.get('/shared-with-alice.txt', { headers: { accept: 'text/html' } }) + .end((err, { status, text }) => { + expect(status).to.equal(401) + expect(text).to.contain('GlobalDashboard') + done(err) + }) + }) + }) + + describe('Two Pods + Web App Login Workflow', () => { + const aliceAccount = UserAccount.from({ webId: aliceWebId }) + const alicePassword = '12345' + + let auth + let authorizationUri, loginUri, authParams, callbackUri + let loginFormFields = '' + let bearerToken + let cookie + let postLoginUri + + before(() => { + auth = new SolidAuthOIDC({ store: localStorage, window: { location: {} } }) + const appOptions = { + redirectUri: 'https://app.example.com/callback' + } + + aliceUserStore.initCollections() + + return aliceUserStore.createUser(aliceAccount, alicePassword) + .then(() => { + return auth.registerClient(aliceServerUri, appOptions) + }) + .then(registeredClient => { + auth.currentClient = registeredClient + }) + }) + + after(() => { + fs.removeSync(path.join(aliceDbPath, 'users/users')) + fs.removeSync(path.join(aliceDbPath, 'oidc/op/tokens')) + + const clientId = auth.currentClient.registration.client_id + const registration = `_key_${clientId}.json` + fs.removeSync(path.join(aliceDbPath, 'oidc/op/clients', registration)) + }) + + // Step 1: An app makes a GET request and receives a 401 + it('should get a 401 error on a REST request to a protected resource', () => { + return fetch(bobServerUri + '/shared-with-alice.txt') + .then(res => { + expect(res.status).to.equal(401) + + expect(res.headers.get('www-authenticate')) + .to.equal(`Bearer realm="${bobServerUri}", scope="openid webid"`) + }) + }) + + // Step 2: App presents the Select Provider UI to user, determine the + // preferred provider uri (here, aliceServerUri), and constructs + // an authorization uri for that provider + it('should determine the authorization uri for a preferred provider', () => { + return auth.currentClient.createRequest({}, auth.store) + .then(authUri => { + authorizationUri = authUri + + expect(authUri.startsWith(aliceServerUri + '/authorize')).to.be.true() + }) + }) + + // Step 3: App redirects user to the authorization uri for login + it('should redirect user to /authorize and /login', () => { + return fetch(authorizationUri, { redirect: 'manual' }) + .then(res => { + // Since user is not logged in, /authorize redirects to /login + expect(res.status).to.equal(302) + + loginUri = new URL(res.headers.get('location'), aliceServerUri) + expect(loginUri.toString().startsWith(aliceServerUri + '/login')) + .to.be.true() + + authParams = loginUri.searchParams + }) + }) + + // Step 4: Pod returns a /login page with appropriate hidden form fields + it('should display the /login form', () => { + return fetch(loginUri.toString()) + .then(loginPage => { + return loginPage.text() + }) + .then(pageText => { + // Login page should contain the relevant auth params as hidden fields + + authParams.forEach((value, key) => { + const hiddenField = `` + + const fieldRegex = new RegExp(hiddenField) + + expect(pageText).to.match(fieldRegex) + + loginFormFields += `${key}=` + encodeURIComponent(value) + '&' + }) + }) + }) + + // Step 5: User submits their username & password via the /login form + it('should login via the /login form', () => { + loginFormFields += `username=${'alice'}&password=${alicePassword}` + + return fetch(aliceServerUri + '/login/password', { + method: 'POST', + body: loginFormFields, + redirect: 'manual', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + }, + credentials: 'include' + }) + .then(res => { + expect(res.status).to.equal(302) + const location = res.headers.get('location') + postLoginUri = new URL(location, aliceServerUri).toString() + // Native fetch: get first set-cookie header + const setCookieHeaders = res.headers.getSetCookie ? res.headers.getSetCookie() : [res.headers.get('set-cookie')] + cookie = setCookieHeaders[0] + + // Successful login gets redirected back to /authorize and then + // back to app + expect(postLoginUri.startsWith(aliceServerUri + '/sharing')) + .to.be.true() + }) + }) + + // Step 6: User consents to the app accessing certain things + it('should consent via the /sharing form', () => { + loginFormFields += '&access_mode=Read&access_mode=Write&consent=true' + + return fetch(aliceServerUri + '/sharing', { + method: 'POST', + body: loginFormFields, + redirect: 'manual', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + cookie + }, + credentials: 'include' + }) + .then(res => { + expect(res.status).to.equal(302) + const location = res.headers.get('location') + const postSharingUri = new URL(location, aliceServerUri).toString() + const setCookieHeaders = res.headers.getSetCookie ? res.headers.getSetCookie() : [res.headers.get('set-cookie')] + const cookieFromSharing = setCookieHeaders[0] || cookie + + // Successful login gets redirected back to /authorize and then + // back to app + expect(postSharingUri.startsWith(aliceServerUri + '/authorize')) + .to.be.true() + + return fetch(postSharingUri, { redirect: 'manual', headers: { cookie: cookieFromSharing } }) + }) + .then(res => { + // User gets redirected back to original app + expect(res.status).to.equal(302) + const location = res.headers.get('location') + callbackUri = location.startsWith('http') ? location : new URL(location, aliceServerUri).toString() + expect(callbackUri.startsWith('https://app.example.com#')) + }) + }) + + // Step 6: Web App extracts tokens from the uri hash fragment, uses + // them to access protected resource + it('should use id token from the callback uri to access shared resource (no origin)', () => { + auth.window.location.href = callbackUri + + const protectedResourcePath = bobServerUri + '/shared-with-alice.txt' + + return auth.initUserFromResponse(auth.currentClient) + .then(webId => { + expect(webId).to.equal(aliceWebId) + + return auth.issuePoPTokenFor(bobServerUri, auth.session) + }) + .then(popToken => { + bearerToken = popToken + + return fetch(protectedResourcePath, { + headers: { + Authorization: 'Bearer ' + bearerToken + } + }) + }) + .then(res => { + expect(res.status).to.equal(200) + + return res.text() + }) + .then(contents => { + expect(contents).to.equal('protected contents\n') + }) + }) + it('should use id token from the callback uri to access shared resource (untrusted origin)', () => { + auth.window.location.href = callbackUri + + const protectedResourcePath = bobServerUri + '/shared-with-alice.txt' + + return auth.initUserFromResponse(auth.currentClient) + .then(webId => { + expect(webId).to.equal(aliceWebId) + + return auth.issuePoPTokenFor(bobServerUri, auth.session) + }) + .then(popToken => { + bearerToken = popToken + + return fetch(protectedResourcePath, { + headers: { + Authorization: 'Bearer ' + bearerToken, + Origin: 'https://untrusted.example.com' // shouldn't matter if strictOrigin is set to false + } + }) + }) + .then(res => { + expect(res.status).to.equal(200) + + return res.text() + }) + .then(contents => { + expect(contents).to.equal('protected contents\n') + }) + }) + + it('should not be able to reuse the bearer token for bob server on another server', () => { + const privateAliceResourcePath = aliceServerUri + '/private-for-alice.txt' + + return fetch(privateAliceResourcePath, { + headers: { + // This is Alice's bearer token with her own Web ID + Authorization: 'Bearer ' + bearerToken + } + }) + .then(res => { + // It will get rejected; it was issued for Bob's server only + expect(res.status).to.equal(403) + }) + }) + }) + + describe('Post-logout page (GET /goodbye)', () => { + it('should load the post-logout page', () => { + return alice.get('/goodbye') + .expect(200) + }) + }) +}) diff --git a/test/integration/capability-discovery-test.mjs b/test/integration/capability-discovery-test.js similarity index 94% rename from test/integration/capability-discovery-test.mjs rename to test/integration/capability-discovery-test.js index 67acd6806..6bc1697d3 100644 --- a/test/integration/capability-discovery-test.mjs +++ b/test/integration/capability-discovery-test.js @@ -1,115 +1,115 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import supertest from 'supertest' -import chai from 'chai' -import { cleanDir } from '../utils.mjs' -import * as Solid from '../../index.mjs' -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const { expect } = chai - -// In this test we always assume that we are Alice - -describe('API', () => { - let alice - - const aliceServerUri = 'https://localhost:5000' - const configPath = path.join(__dirname, '../resources/config') - const aliceDbPath = path.join(__dirname, - '../resources/accounts-scenario/alice/db') - const aliceRootPath = path.join(__dirname, '../resources/accounts-scenario/alice') - - const serverConfig = { - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), - auth: 'oidc', - dataBrowser: false, - webid: true, - multiuser: false, - configPath - } - - const alicePod = Solid.createServer( - Object.assign({ - root: aliceRootPath, - serverUri: aliceServerUri, - dbPath: aliceDbPath - }, serverConfig) - ) - - function startServer (pod, port) { - return new Promise((resolve) => { - pod.listen(port, () => { resolve() }) - }) - } - - before(() => { - return Promise.all([ - startServer(alicePod, 5000) - ]).then(() => { - alice = supertest(aliceServerUri) - }) - }) - - after(() => { - alicePod.close() - cleanDir(aliceRootPath) - }) - - describe('Capability Discovery', () => { - describe('GET Service Capability document', () => { - it('should exist', (done) => { - alice.get('/.well-known/solid') - .expect(200, done) - }) - it('should be a json file by default', (done) => { - alice.get('/.well-known/solid') - .expect('content-type', /application\/json/) - .expect(200, done) - }) - it('includes a root element', (done) => { - alice.get('/.well-known/solid') - .end(function (err, req) { - expect(req.body.root).to.exist - return done(err) - }) - }) - it('includes an apps config section', (done) => { - const config = { - apps: { - signin: '/signin/', - signup: '/signup/' - }, - webid: false - } - const solid = Solid.createServer(config) - const server = supertest(solid) - server.get('/.well-known/solid') - .end(function (err, req) { - expect(req.body.apps).to.exist - return done(err) - }) - }) - }) - - describe('OPTIONS API', () => { - it('should return the service Link header', (done) => { - alice.options('/') - .expect('Link', /<.*\.well-known\/solid>; rel="service"/) - .expect(204, done) - }) - - it('should return the http://openid.net/specs/connect/1.0/issuer Link rel header', (done) => { - alice.options('/') - .expect('Link', /; rel="http:\/\/openid\.net\/specs\/connect\/1\.0\/issuer"/) - .expect(204, done) - }) - - it('should return a service Link header without multiple slashes', (done) => { - alice.options('/') - .expect('Link', /<.*[^/]\/\.well-known\/solid>; rel="service"/) - .expect(204, done) - }) - }) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import supertest from 'supertest' +import chai from 'chai' +import { cleanDir } from '../utils.js' +import * as Solid from '../../index.js' +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const { expect } = chai + +// In this test we always assume that we are Alice + +describe('API', () => { + let alice + + const aliceServerUri = 'https://localhost:5000' + const configPath = path.join(__dirname, '../resources/config') + const aliceDbPath = path.join(__dirname, + '../resources/accounts-scenario/alice/db') + const aliceRootPath = path.join(__dirname, '../resources/accounts-scenario/alice') + + const serverConfig = { + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + auth: 'oidc', + dataBrowser: false, + webid: true, + multiuser: false, + configPath + } + + const alicePod = Solid.createServer( + Object.assign({ + root: aliceRootPath, + serverUri: aliceServerUri, + dbPath: aliceDbPath + }, serverConfig) + ) + + function startServer (pod, port) { + return new Promise((resolve) => { + pod.listen(port, () => { resolve() }) + }) + } + + before(() => { + return Promise.all([ + startServer(alicePod, 5000) + ]).then(() => { + alice = supertest(aliceServerUri) + }) + }) + + after(() => { + alicePod.close() + cleanDir(aliceRootPath) + }) + + describe('Capability Discovery', () => { + describe('GET Service Capability document', () => { + it('should exist', (done) => { + alice.get('/.well-known/solid') + .expect(200, done) + }) + it('should be a json file by default', (done) => { + alice.get('/.well-known/solid') + .expect('content-type', /application\/json/) + .expect(200, done) + }) + it('includes a root element', (done) => { + alice.get('/.well-known/solid') + .end(function (err, req) { + expect(req.body.root).to.exist + return done(err) + }) + }) + it('includes an apps config section', (done) => { + const config = { + apps: { + signin: '/signin/', + signup: '/signup/' + }, + webid: false + } + const solid = Solid.createServer(config) + const server = supertest(solid) + server.get('/.well-known/solid') + .end(function (err, req) { + expect(req.body.apps).to.exist + return done(err) + }) + }) + }) + + describe('OPTIONS API', () => { + it('should return the service Link header', (done) => { + alice.options('/') + .expect('Link', /<.*\.well-known\/solid>; rel="service"/) + .expect(204, done) + }) + + it('should return the http://openid.net/specs/connect/1.0/issuer Link rel header', (done) => { + alice.options('/') + .expect('Link', /; rel="http:\/\/openid\.net\/specs\/connect\/1\.0\/issuer"/) + .expect(204, done) + }) + + it('should return a service Link header without multiple slashes', (done) => { + alice.options('/') + .expect('Link', /<.*[^/]\/\.well-known\/solid>; rel="service"/) + .expect(204, done) + }) + }) + }) +}) diff --git a/test/integration/cors-proxy-test.mjs b/test/integration/cors-proxy-test.js similarity index 96% rename from test/integration/cors-proxy-test.mjs rename to test/integration/cors-proxy-test.js index 9667ec6d4..ef80130fb 100644 --- a/test/integration/cors-proxy-test.mjs +++ b/test/integration/cors-proxy-test.js @@ -1,145 +1,145 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import chai from 'chai' -import nock from 'nock' - -// Import utility functions from the ESM utils -import { checkDnsSettings, setupSupertestServer } from '../utils.mjs' - -const { assert } = chai - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -describe('CORS Proxy', () => { - const server = setupSupertestServer({ - root: path.join(__dirname, '../resources'), - corsProxy: '/proxy', - webid: false - }) - - before(checkDnsSettings) - - it('should return the website in /proxy?uri', (done) => { - nock('https://example.org').get('/').reply(200) - server.get('/proxy?uri=https://example.org/') - .expect(200, done) - }) - - it('should pass the Host header to the proxied server', (done) => { - let headers - nock('https://example.org').get('/').reply(function (uri, body) { - headers = this.req.headers - return [200] - }) - server.get('/proxy?uri=https://example.org/') - .expect(200) - .end(error => { - assert.propertyVal(headers, 'host', 'example.org') - done(error) - }) - }) - - it('should return 400 when the uri parameter is missing', (done) => { - nock('https://192.168.0.0').get('/').reply(200) - server.get('/proxy') - .expect('Invalid URL passed: (none)') - .expect(400) - .end(done) - }) - - const LOCAL_IPS = [ - '127.0.0.0', - '10.0.0.0', - '172.16.0.0', - '192.168.0.0', - '[::1]' - ] - LOCAL_IPS.forEach(ip => { - it(`should return 400 for a ${ip} address`, (done) => { - nock(`https://${ip}`).get('/').reply(200) - server.get(`/proxy?uri=https://${ip}/`) - .expect(`Cannot proxy https://${ip}/`) - .expect(400) - .end(done) - }) - }) - - it('should return 400 with a local hostname', (done) => { - nock('https://nic.localhost').get('/').reply(200) - server.get('/proxy?uri=https://nic.localhost/') - .expect('Cannot proxy https://nic.localhost/') - .expect(400) - .end(done) - }) - - it('should return 400 on invalid uri', (done) => { - server.get('/proxy?uri=HELLOWORLD') - .expect('Invalid URL passed: HELLOWORLD') - .expect(400) - .end(done) - }) - - it('should return 400 on relative paths', (done) => { - server.get('/proxy?uri=../') - .expect('Invalid URL passed: ../') - .expect(400) - .end(done) - }) - - it('should return the same headers of proxied request', (done) => { - nock('https://example.org') - .get('/') - .reply(function (uri, req) { - if (this.req.headers.accept !== 'text/turtle') { - throw Error('Accept is received on the header') - } - if (this.req.headers.test && this.req.headers.test === 'test1') { - return [200, 'YES'] - } else { - return [500, 'empty'] - } - }) - - server.get('/proxy?uri=https://example.org/') - .set('test', 'test1') - .set('accept', 'text/turtle') - .expect(200) - .end((err, data) => { - if (err) return done(err) - done(err) - }) - }) - - it('should also work on /proxy/ ?uri', (done) => { - nock('https://example.org').get('/').reply(200) - server.get('/proxy/?uri=https://example.org/') - .expect((a) => { - assert.equal(a.header.link, null) - }) - .expect(200, done) - }) - - it('should return the same HTTP status code as the uri', () => { - nock('https://example.org') - .get('/404').reply(404) - .get('/401').reply(401) - .get('/500').reply(500) - .get('/200').reply(200) - - return Promise.all([ - server.get('/proxy/?uri=https://example.org/404').expect(404), - server.get('/proxy/?uri=https://example.org/401').expect(401), - server.get('/proxy/?uri=https://example.org/500').expect(500), - server.get('/proxy/?uri=https://example.org/200').expect(200) - ]) - }) - - it('should work with cors', (done) => { - nock('https://example.org').get('/').reply(200) - server.get('/proxy/?uri=https://example.org/') - .set('Origin', 'http://example.com') - .expect('Access-Control-Allow-Origin', 'http://example.com') - .expect(200, done) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import chai from 'chai' +import nock from 'nock' + +// Import utility functions from the ESM utils +import { checkDnsSettings, setupSupertestServer } from '../utils.js' + +const { assert } = chai + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +describe('CORS Proxy', () => { + const server = setupSupertestServer({ + root: path.join(__dirname, '../resources'), + corsProxy: '/proxy', + webid: false + }) + + before(checkDnsSettings) + + it('should return the website in /proxy?uri', (done) => { + nock('https://example.org').get('/').reply(200) + server.get('/proxy?uri=https://example.org/') + .expect(200, done) + }) + + it('should pass the Host header to the proxied server', (done) => { + let headers + nock('https://example.org').get('/').reply(function (uri, body) { + headers = this.req.headers + return [200] + }) + server.get('/proxy?uri=https://example.org/') + .expect(200) + .end(error => { + assert.propertyVal(headers, 'host', 'example.org') + done(error) + }) + }) + + it('should return 400 when the uri parameter is missing', (done) => { + nock('https://192.168.0.0').get('/').reply(200) + server.get('/proxy') + .expect('Invalid URL passed: (none)') + .expect(400) + .end(done) + }) + + const LOCAL_IPS = [ + '127.0.0.0', + '10.0.0.0', + '172.16.0.0', + '192.168.0.0', + '[::1]' + ] + LOCAL_IPS.forEach(ip => { + it(`should return 400 for a ${ip} address`, (done) => { + nock(`https://${ip}`).get('/').reply(200) + server.get(`/proxy?uri=https://${ip}/`) + .expect(`Cannot proxy https://${ip}/`) + .expect(400) + .end(done) + }) + }) + + it('should return 400 with a local hostname', (done) => { + nock('https://nic.localhost').get('/').reply(200) + server.get('/proxy?uri=https://nic.localhost/') + .expect('Cannot proxy https://nic.localhost/') + .expect(400) + .end(done) + }) + + it('should return 400 on invalid uri', (done) => { + server.get('/proxy?uri=HELLOWORLD') + .expect('Invalid URL passed: HELLOWORLD') + .expect(400) + .end(done) + }) + + it('should return 400 on relative paths', (done) => { + server.get('/proxy?uri=../') + .expect('Invalid URL passed: ../') + .expect(400) + .end(done) + }) + + it('should return the same headers of proxied request', (done) => { + nock('https://example.org') + .get('/') + .reply(function (uri, req) { + if (this.req.headers.accept !== 'text/turtle') { + throw Error('Accept is received on the header') + } + if (this.req.headers.test && this.req.headers.test === 'test1') { + return [200, 'YES'] + } else { + return [500, 'empty'] + } + }) + + server.get('/proxy?uri=https://example.org/') + .set('test', 'test1') + .set('accept', 'text/turtle') + .expect(200) + .end((err, data) => { + if (err) return done(err) + done(err) + }) + }) + + it('should also work on /proxy/ ?uri', (done) => { + nock('https://example.org').get('/').reply(200) + server.get('/proxy/?uri=https://example.org/') + .expect((a) => { + assert.equal(a.header.link, null) + }) + .expect(200, done) + }) + + it('should return the same HTTP status code as the uri', () => { + nock('https://example.org') + .get('/404').reply(404) + .get('/401').reply(401) + .get('/500').reply(500) + .get('/200').reply(200) + + return Promise.all([ + server.get('/proxy/?uri=https://example.org/404').expect(404), + server.get('/proxy/?uri=https://example.org/401').expect(401), + server.get('/proxy/?uri=https://example.org/500').expect(500), + server.get('/proxy/?uri=https://example.org/200').expect(200) + ]) + }) + + it('should work with cors', (done) => { + nock('https://example.org').get('/').reply(200) + server.get('/proxy/?uri=https://example.org/') + .set('Origin', 'http://example.com') + .expect('Access-Control-Allow-Origin', 'http://example.com') + .expect(200, done) + }) +}) diff --git a/test/integration/errors-oidc-test.mjs b/test/integration/errors-oidc-test.js similarity index 95% rename from test/integration/errors-oidc-test.mjs rename to test/integration/errors-oidc-test.js index 2e41ec47c..8b86b4a55 100644 --- a/test/integration/errors-oidc-test.mjs +++ b/test/integration/errors-oidc-test.js @@ -1,109 +1,109 @@ -import { expect } from 'chai' -import supertest from 'supertest' -import ldnode from '../../index.mjs' -import path from 'path' -import { fileURLToPath } from 'url' -import { cleanDir, cp } from '../utils.mjs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -describe('OIDC error handling', function () { - const serverUri = 'https://localhost:3457' - let ldpHttpsServer - const rootPath = path.normalize(path.join(__dirname, '../resources/accounts/errortests')) - const configPath = path.normalize(path.join(__dirname, '../resources/config')) - const dbPath = path.normalize(path.join(__dirname, '../resources/accounts/db')) - - const ldp = ldnode.createServer({ - root: rootPath, - configPath, - sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), - sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), - auth: 'oidc', - webid: true, - multiuser: false, - strictOrigin: true, - dbPath, - serverUri - }) - - before(function (done) { - ldpHttpsServer = ldp.listen(3457, () => { - cp(path.normalize(path.join('accounts/errortests', '.acl-override')), path.normalize(path.join('accounts/errortests', '.acl'))) - done() - }) - }) - - after(function () { - if (ldpHttpsServer) ldpHttpsServer.close() - cleanDir(rootPath) - }) - - const server = supertest(serverUri) - - describe('Unauthenticated requests to protected resources', () => { - describe('accepting text/html', () => { - it('should return 401 Unauthorized with www-auth header', () => { - return server.get('/profile/') - .set('Accept', 'text/html') - .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid"') - .expect(401) - }) - - it('should return an html login page', () => { - return server.get('/profile/') - .set('Accept', 'text/html') - .expect('Content-Type', 'text/html; charset=utf-8') - .then(res => { - expect(res.text).to.match(/GlobalDashboard/) - }) - }) - }) - - describe('not accepting html', () => { - it('should return 401 Unauthorized with www-auth header', () => { - return server.get('/profile/') - .set('Accept', 'text/plain') - .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid"') - .expect(401) - }) - }) - }) - - describe('Authenticated responses to protected resources', () => { - describe('with an empty bearer token', () => { - it('should return a 400 error', () => { - return server.get('/profile/') - .set('Authorization', 'Bearer ') - .expect(400) - }) - }) - - describe('with an invalid bearer token', () => { - it('should return a 401 error', () => { - return server.get('/profile/') - .set('Authorization', 'Bearer abcd123') - .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid", error="invalid_token", error_description="Access token is not a JWT"') - .expect(401) - }) - }) - - describe('with an expired bearer token', () => { - const expiredToken = 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImxOWk9CLURQRTFrIn0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDozNDU3Iiwic3ViIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6MzQ1Ny9wcm9maWxlL2NhcmQjbWUiLCJhdWQiOiJodHRwczovL2xvY2FsaG9zdDozNDU3IiwiZXhwIjoxNDk2MjM5ODY1LCJpYXQiOjE0OTYyMzk4NjUsImp0aSI6IjliN2MwNGQyNDY3MjQ1ZWEiLCJub25jZSI6IklXaUpMVFNZUmktVklSSlhjejVGdU9CQTFZR1lZNjFnRGRlX2JnTEVPMDAiLCJhdF9oYXNoIjoiRFpES3I0RU1xTGE1Q0x1elV1WW9pdyJ9.uBTLy_wG5rr4kxM0hjXwIC-NwGYrGiiiY9IdOk5hEjLj2ECc767RU7iZ5vZa0pSrGy0V2Y3BiZ7lnYIA7N4YUAuS077g_4zavoFWyu9xeq6h70R8yfgFUNPo91PGpODC9hgiNbEv2dPBzTYYHqf7D6_-3HGnnDwiX7TjWLTkPLRvPLTcsCUl7G7y-EedjcVRk3Jyv8TNSoBMeTwOR3ewuzNostmCjUuLsr73YpVid6HE55BBqgSCDCNtS-I7nYmO_lRqIWJCydjdStSMJgxzSpASvoeCJ_lwZF6FXmZOQNNhmstw69fU85J1_QsS78cRa76-SnJJp6JCWHFBUAolPQ' - - it('should return a 401 error', () => { - return server.get('/profile/') - .set('Authorization', 'Bearer ' + expiredToken) - .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid", error="invalid_token", error_description="Access token is expired"') - .expect(401) - }) - - it('should return a 200 if the resource is public', () => { - return server.get('/public/') - .set('Authorization', 'Bearer ' + expiredToken) - .expect(200) - }) - }) - }) -}) +import { expect } from 'chai' +import supertest from 'supertest' +import ldnode from '../../index.js' +import path from 'path' +import { fileURLToPath } from 'url' +import { cleanDir, cp } from '../utils.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +describe('OIDC error handling', function () { + const serverUri = 'https://localhost:3457' + let ldpHttpsServer + const rootPath = path.normalize(path.join(__dirname, '../resources/accounts/errortests')) + const configPath = path.normalize(path.join(__dirname, '../resources/config')) + const dbPath = path.normalize(path.join(__dirname, '../resources/accounts/db')) + + const ldp = ldnode.createServer({ + root: rootPath, + configPath, + sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), + sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), + auth: 'oidc', + webid: true, + multiuser: false, + strictOrigin: true, + dbPath, + serverUri + }) + + before(function (done) { + ldpHttpsServer = ldp.listen(3457, () => { + cp(path.normalize(path.join('accounts/errortests', '.acl-override')), path.normalize(path.join('accounts/errortests', '.acl'))) + done() + }) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + cleanDir(rootPath) + }) + + const server = supertest(serverUri) + + describe('Unauthenticated requests to protected resources', () => { + describe('accepting text/html', () => { + it('should return 401 Unauthorized with www-auth header', () => { + return server.get('/profile/') + .set('Accept', 'text/html') + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid"') + .expect(401) + }) + + it('should return an html login page', () => { + return server.get('/profile/') + .set('Accept', 'text/html') + .expect('Content-Type', 'text/html; charset=utf-8') + .then(res => { + expect(res.text).to.match(/GlobalDashboard/) + }) + }) + }) + + describe('not accepting html', () => { + it('should return 401 Unauthorized with www-auth header', () => { + return server.get('/profile/') + .set('Accept', 'text/plain') + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid"') + .expect(401) + }) + }) + }) + + describe('Authenticated responses to protected resources', () => { + describe('with an empty bearer token', () => { + it('should return a 400 error', () => { + return server.get('/profile/') + .set('Authorization', 'Bearer ') + .expect(400) + }) + }) + + describe('with an invalid bearer token', () => { + it('should return a 401 error', () => { + return server.get('/profile/') + .set('Authorization', 'Bearer abcd123') + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid", error="invalid_token", error_description="Access token is not a JWT"') + .expect(401) + }) + }) + + describe('with an expired bearer token', () => { + const expiredToken = 'eyJhbGciOiJSUzI1NiIsImtpZCI6ImxOWk9CLURQRTFrIn0.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDozNDU3Iiwic3ViIjoiaHR0cHM6Ly9sb2NhbGhvc3Q6MzQ1Ny9wcm9maWxlL2NhcmQjbWUiLCJhdWQiOiJodHRwczovL2xvY2FsaG9zdDozNDU3IiwiZXhwIjoxNDk2MjM5ODY1LCJpYXQiOjE0OTYyMzk4NjUsImp0aSI6IjliN2MwNGQyNDY3MjQ1ZWEiLCJub25jZSI6IklXaUpMVFNZUmktVklSSlhjejVGdU9CQTFZR1lZNjFnRGRlX2JnTEVPMDAiLCJhdF9oYXNoIjoiRFpES3I0RU1xTGE1Q0x1elV1WW9pdyJ9.uBTLy_wG5rr4kxM0hjXwIC-NwGYrGiiiY9IdOk5hEjLj2ECc767RU7iZ5vZa0pSrGy0V2Y3BiZ7lnYIA7N4YUAuS077g_4zavoFWyu9xeq6h70R8yfgFUNPo91PGpODC9hgiNbEv2dPBzTYYHqf7D6_-3HGnnDwiX7TjWLTkPLRvPLTcsCUl7G7y-EedjcVRk3Jyv8TNSoBMeTwOR3ewuzNostmCjUuLsr73YpVid6HE55BBqgSCDCNtS-I7nYmO_lRqIWJCydjdStSMJgxzSpASvoeCJ_lwZF6FXmZOQNNhmstw69fU85J1_QsS78cRa76-SnJJp6JCWHFBUAolPQ' + + it('should return a 401 error', () => { + return server.get('/profile/') + .set('Authorization', 'Bearer ' + expiredToken) + .expect('WWW-Authenticate', 'Bearer realm="https://localhost:3457", scope="openid webid", error="invalid_token", error_description="Access token is expired"') + .expect(401) + }) + + it('should return a 200 if the resource is public', () => { + return server.get('/public/') + .set('Authorization', 'Bearer ' + expiredToken) + .expect(200) + }) + }) + }) +}) diff --git a/test/integration/errors-test.mjs b/test/integration/errors-test.js similarity index 92% rename from test/integration/errors-test.mjs rename to test/integration/errors-test.js index bf0c20a97..0f7f188fd 100644 --- a/test/integration/errors-test.mjs +++ b/test/integration/errors-test.js @@ -1,49 +1,49 @@ -import { fileURLToPath } from 'url' -import { dirname, join } from 'path' -import { read, setupSupertestServer } from '../utils.mjs' - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -describe('Error pages', function () { - // LDP with error pages - const errorServer = setupSupertestServer({ - root: join(__dirname, '../resources'), - errorPages: join(__dirname, '../resources/errorPages'), - webid: false - }) - - // LDP with no error pages - const noErrorServer = setupSupertestServer({ - root: join(__dirname, '../resources'), - noErrorPages: true, - webid: false - }) - - function defaultErrorPage (filepath, expected) { - const handler = function (res) { - const errorFile = read(filepath) - if (res.text === errorFile && !expected) { - console.log('Not default text') - } - } - return handler - } - - describe('noErrorPages', function () { - const file404 = 'errorPages/404.html' - it('Should return 404 express default page', function (done) { - noErrorServer.get('/non-existent-file.html') - .expect(defaultErrorPage(file404, false)) - .expect(404, done) - }) - }) - - describe('errorPages set', function () { - const file404 = 'errorPages/404.html' - it('Should return 404 custom page if exists', function (done) { - errorServer.get('/non-existent-file.html') - .expect(defaultErrorPage(file404, true)) - .expect(404, done) - }) - }) -}) +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import { read, setupSupertestServer } from '../utils.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +describe('Error pages', function () { + // LDP with error pages + const errorServer = setupSupertestServer({ + root: join(__dirname, '../resources'), + errorPages: join(__dirname, '../resources/errorPages'), + webid: false + }) + + // LDP with no error pages + const noErrorServer = setupSupertestServer({ + root: join(__dirname, '../resources'), + noErrorPages: true, + webid: false + }) + + function defaultErrorPage (filepath, expected) { + const handler = function (res) { + const errorFile = read(filepath) + if (res.text === errorFile && !expected) { + console.log('Not default text') + } + } + return handler + } + + describe('noErrorPages', function () { + const file404 = 'errorPages/404.html' + it('Should return 404 express default page', function (done) { + noErrorServer.get('/non-existent-file.html') + .expect(defaultErrorPage(file404, false)) + .expect(404, done) + }) + }) + + describe('errorPages set', function () { + const file404 = 'errorPages/404.html' + it('Should return 404 custom page if exists', function (done) { + errorServer.get('/non-existent-file.html') + .expect(defaultErrorPage(file404, true)) + .expect(404, done) + }) + }) +}) diff --git a/test/integration/formats-test.mjs b/test/integration/formats-test.js similarity index 96% rename from test/integration/formats-test.mjs rename to test/integration/formats-test.js index f45058673..376754bf4 100644 --- a/test/integration/formats-test.mjs +++ b/test/integration/formats-test.js @@ -1,136 +1,136 @@ -import { assert } from 'chai' -import { fileURLToPath } from 'url' -import { dirname, join } from 'path' -import { setupSupertestServer } from '../utils.mjs' - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -describe('formats', function () { - const server = setupSupertestServer({ - root: join(__dirname, '../resources'), - webid: false - }) - - describe('HTML', function () { - it('should return HTML containing "Hello, World!" if Accept is set to text/html', function (done) { - server.get('/hello.html') - .set('accept', 'application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5') - .expect('Content-type', /text\/html/) - .expect(/Hello, world!/) - .expect(200, done) - }) - }) - - describe('JSON-LD', function () { - function isCorrectSubject (idFragment) { - return (res) => { - const payload = JSON.parse(res.text) - const id = payload['@id'] - assert(id.endsWith(idFragment), 'The subject of the JSON-LD graph is correct') - } - } - function isValidJSON (res) { - // This would throw an error - JSON.parse(res.text) - } - it('should return JSON-LD document if Accept is set to only application/ld+json', function (done) { - server.get('/patch-5-initial.ttl') - .set('accept', 'application/ld+json') - .expect(200) - .expect('content-type', /application\/ld\+json/) - .expect(isValidJSON) - .expect(isCorrectSubject(':Iss1408851516666')) - .end(done) - }) - it('should return the container listing in JSON-LD if Accept is set to only application/ld+json', function (done) { - server.get('/') - .set('accept', 'application/ld+json') - .expect(200) - .expect('content-type', /application\/ld\+json/) - .end(done) - }) - it('should prefer to avoid translation even if type is listed with less priority', function (done) { - server.get('/patch-5-initial.ttl') - .set('accept', 'application/ld+json;q=0.9,text/turtle;q=0.8,text/plain;q=0.7,*/*;q=0.5') - .expect('content-type', /text\/turtle/) - .expect(200, done) - }) - it('should return JSON-LD document if Accept is set to application/ld+json and other types', function (done) { - server.get('/patch-5-initial.ttl') - .set('accept', 'application/ld+json;q=0.9,application/rdf+xml;q=0.7') - .expect('content-type', /application\/ld\+json/) - .expect(200, done) - }) - }) - - describe('N-Quads', function () { - it('should return N-Quads document is Accept is set to application/n-quads', function (done) { - server.get('/patch-5-initial.ttl') - .set('accept', 'application/n-quads;q=0.9,application/ld+json;q=0.8,application/rdf+xml;q=0.7') - .expect('content-type', /application\/n-quads/) - .expect(200, done) - }) - }) - - describe('n3', function () { - it('should return turtle document if Accept is set to text/n3', function (done) { - server.get('/patch-5-initial.ttl') - .set('accept', 'text/n3;q=0.9,application/n-quads;q=0.7,text/plain;q=0.7') - .expect('content-type', /text\/n3/) - .expect(200, done) - }) - }) - - describe('turtle', function () { - it('should return turtle document if Accept is set to turtle', function (done) { - server.get('/patch-5-initial.ttl') - .set('accept', 'text/turtle;q=0.9,application/rdf+xml;q=0.8,text/plain;q=0.7,*/*;q=0.5') - .expect('content-type', /text\/turtle/) - .expect(200, done) - }) - - it('should return turtle document if Accept is set to turtle', function (done) { - server.get('/lennon.jsonld') - .set('accept', 'text/turtle') - .expect('content-type', /text\/turtle/) - .expect(200, done) - }) - - it('should return turtle when listing container with an index page', function (done) { - server.get('/sampleContainer/') - .set('accept', 'application/rdf+xml;q=0.4, application/xhtml+xml;q=0.3, text/xml;q=0.2, application/xml;q=0.2, text/html;q=0.3, text/plain;q=0.1, text/turtle;q=1.0, application/n3;q=1') - .expect('content-type', /text\/html/) - .expect(200, done) - }) - - it('should return turtle when listing container without an index page', function (done) { - server.get('/sampleContainer2/') - .set('accept', 'application/rdf+xml;q=0.4, application/xhtml+xml;q=0.3, text/xml;q=0.2, application/xml;q=0.2, text/html;q=0.3, text/plain;q=0.1, text/turtle;q=1.0, application/n3;q=1') - .expect('content-type', /text\/turtle/) - .expect(200, done) - }) - }) - - describe('text/plain (non RDFs)', function () { - it('Accept text/plain', function (done) { - server.get('/put-input.txt') - .set('accept', 'text/plain') - .expect('Content-type', /text\/plain/) - .expect(200, done) - }) - it('Accept text/turtle', function (done) { - server.get('/put-input.txt') - .set('accept', 'text/turtle') - .expect('Content-type', /text\/plain/) - .expect(406, done) - }) - }) - - describe('none', function () { - it('should return turtle document if no Accept header is set', function (done) { - server.get('/patch-5-initial.ttl') - .expect('content-type', /text\/turtle/) - .expect(200, done) - }) - }) -}) +import { assert } from 'chai' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import { setupSupertestServer } from '../utils.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +describe('formats', function () { + const server = setupSupertestServer({ + root: join(__dirname, '../resources'), + webid: false + }) + + describe('HTML', function () { + it('should return HTML containing "Hello, World!" if Accept is set to text/html', function (done) { + server.get('/hello.html') + .set('accept', 'application/xml,application/xhtml+xml,text/html;q=0.9,text/plain;q=0.8,image/png,*/*;q=0.5') + .expect('Content-type', /text\/html/) + .expect(/Hello, world!/) + .expect(200, done) + }) + }) + + describe('JSON-LD', function () { + function isCorrectSubject (idFragment) { + return (res) => { + const payload = JSON.parse(res.text) + const id = payload['@id'] + assert(id.endsWith(idFragment), 'The subject of the JSON-LD graph is correct') + } + } + function isValidJSON (res) { + // This would throw an error + JSON.parse(res.text) + } + it('should return JSON-LD document if Accept is set to only application/ld+json', function (done) { + server.get('/patch-5-initial.ttl') + .set('accept', 'application/ld+json') + .expect(200) + .expect('content-type', /application\/ld\+json/) + .expect(isValidJSON) + .expect(isCorrectSubject(':Iss1408851516666')) + .end(done) + }) + it('should return the container listing in JSON-LD if Accept is set to only application/ld+json', function (done) { + server.get('/') + .set('accept', 'application/ld+json') + .expect(200) + .expect('content-type', /application\/ld\+json/) + .end(done) + }) + it('should prefer to avoid translation even if type is listed with less priority', function (done) { + server.get('/patch-5-initial.ttl') + .set('accept', 'application/ld+json;q=0.9,text/turtle;q=0.8,text/plain;q=0.7,*/*;q=0.5') + .expect('content-type', /text\/turtle/) + .expect(200, done) + }) + it('should return JSON-LD document if Accept is set to application/ld+json and other types', function (done) { + server.get('/patch-5-initial.ttl') + .set('accept', 'application/ld+json;q=0.9,application/rdf+xml;q=0.7') + .expect('content-type', /application\/ld\+json/) + .expect(200, done) + }) + }) + + describe('N-Quads', function () { + it('should return N-Quads document is Accept is set to application/n-quads', function (done) { + server.get('/patch-5-initial.ttl') + .set('accept', 'application/n-quads;q=0.9,application/ld+json;q=0.8,application/rdf+xml;q=0.7') + .expect('content-type', /application\/n-quads/) + .expect(200, done) + }) + }) + + describe('n3', function () { + it('should return turtle document if Accept is set to text/n3', function (done) { + server.get('/patch-5-initial.ttl') + .set('accept', 'text/n3;q=0.9,application/n-quads;q=0.7,text/plain;q=0.7') + .expect('content-type', /text\/n3/) + .expect(200, done) + }) + }) + + describe('turtle', function () { + it('should return turtle document if Accept is set to turtle', function (done) { + server.get('/patch-5-initial.ttl') + .set('accept', 'text/turtle;q=0.9,application/rdf+xml;q=0.8,text/plain;q=0.7,*/*;q=0.5') + .expect('content-type', /text\/turtle/) + .expect(200, done) + }) + + it('should return turtle document if Accept is set to turtle', function (done) { + server.get('/lennon.jsonld') + .set('accept', 'text/turtle') + .expect('content-type', /text\/turtle/) + .expect(200, done) + }) + + it('should return turtle when listing container with an index page', function (done) { + server.get('/sampleContainer/') + .set('accept', 'application/rdf+xml;q=0.4, application/xhtml+xml;q=0.3, text/xml;q=0.2, application/xml;q=0.2, text/html;q=0.3, text/plain;q=0.1, text/turtle;q=1.0, application/n3;q=1') + .expect('content-type', /text\/html/) + .expect(200, done) + }) + + it('should return turtle when listing container without an index page', function (done) { + server.get('/sampleContainer2/') + .set('accept', 'application/rdf+xml;q=0.4, application/xhtml+xml;q=0.3, text/xml;q=0.2, application/xml;q=0.2, text/html;q=0.3, text/plain;q=0.1, text/turtle;q=1.0, application/n3;q=1') + .expect('content-type', /text\/turtle/) + .expect(200, done) + }) + }) + + describe('text/plain (non RDFs)', function () { + it('Accept text/plain', function (done) { + server.get('/put-input.txt') + .set('accept', 'text/plain') + .expect('Content-type', /text\/plain/) + .expect(200, done) + }) + it('Accept text/turtle', function (done) { + server.get('/put-input.txt') + .set('accept', 'text/turtle') + .expect('Content-type', /text\/plain/) + .expect(406, done) + }) + }) + + describe('none', function () { + it('should return turtle document if no Accept header is set', function (done) { + server.get('/patch-5-initial.ttl') + .expect('content-type', /text\/turtle/) + .expect(200, done) + }) + }) +}) diff --git a/test/integration/header-test.mjs b/test/integration/header-test.js similarity index 95% rename from test/integration/header-test.mjs rename to test/integration/header-test.js index fe60ddd00..d47c8708e 100644 --- a/test/integration/header-test.mjs +++ b/test/integration/header-test.js @@ -1,101 +1,101 @@ -import { expect } from 'chai' -import { join } from 'path' -import { setupSupertestServer } from '../utils.mjs' - -const __dirname = import.meta.dirname // dirname(fileURLToPath(import.meta.url)) - -describe('Header handler', () => { - let request - - before(function () { - this.timeout(20000) - request = setupSupertestServer({ - root: join(__dirname, '../resources/headers'), - multiuser: false, - webid: true, - sslKey: join(__dirname, '../keys/key.pem'), - sslCert: join(__dirname, '../keys/cert.pem'), - forceUser: 'https://ruben.verborgh.org/profile/#me' - }) - }) - - describe('MS-Author-Via', () => { // deprecated - describeHeaderTest('read/append for the public', { - resource: '/public-ra', - headers: { - 'MS-Author-Via': 'SPARQL', - 'Access-Control-Expose-Headers': /(^|,\s*)MS-Author-Via(,|$)/ - } - }) - }) - - describe('Accept-* for a resource document', () => { - describeHeaderTest('read/append for the public', { - resource: '/public-ra', - headers: { - 'Accept-Patch': 'text/n3, application/sparql-update, application/sparql-update-single-match', - 'Accept-Post': '*/*', - 'Accept-Put': '*/*', - 'Access-Control-Expose-Headers': /(^|,\s*)Accept-Patch, Accept-Post, Accept-Put(,|$)/ - } - }) - }) - - describe('WAC-Allow', () => { - describeHeaderTest('read/append for the public', { - resource: '/public-ra', - headers: { - 'WAC-Allow': 'user="read append",public="read append"', - 'Access-Control-Expose-Headers': /(^|,\s*)WAC-Allow(,|$)/ - } - }) - - describeHeaderTest('read/write for the user, read for the public', { - resource: '/user-rw-public-r', - headers: { - 'WAC-Allow': 'user="read write append",public="read"', - 'Access-Control-Expose-Headers': /(^|,\s*)WAC-Allow(,|$)/ - } - }) - - // FIXME: https://github.com/solid/node-solid-server/issues/1502 - describeHeaderTest('read/write/append/control for the user, nothing for the public', { - resource: '/user-rwac-public-0', - headers: { - 'WAC-Allow': 'user="read write append control",public=""', - 'Access-Control-Expose-Headers': /(^|,\s*)WAC-Allow(,|$)/ - } - }) - }) - - function describeHeaderTest (label, { resource, headers }) { - describe(`a resource that is ${label}`, () => { - // Retrieve the response headers - const response = {} - before(async function () { - this.timeout(10000) // FIXME: https://github.com/solid/node-solid-server/issues/1443 - const { headers } = await request.get(resource) - response.headers = headers - }) - - // Assert the existence of each of the expected headers - for (const header in headers) { - assertResponseHasHeader(response, header, headers[header]) - } - }) - } - - function assertResponseHasHeader (response, name, value) { - const key = name.toLowerCase() - if (value instanceof RegExp) { - it(`has a ${name} header matching ${value}`, () => { - expect(response.headers).to.have.property(key) - expect(response.headers[key]).to.match(value) - }) - } else { - it(`has a ${name} header of ${value}`, () => { - expect(response.headers).to.have.property(key, value) - }) - } - } -}) +import { expect } from 'chai' +import { join } from 'path' +import { setupSupertestServer } from '../utils.js' + +const __dirname = import.meta.dirname // dirname(fileURLToPath(import.meta.url)) + +describe('Header handler', () => { + let request + + before(function () { + this.timeout(20000) + request = setupSupertestServer({ + root: join(__dirname, '../resources/headers'), + multiuser: false, + webid: true, + sslKey: join(__dirname, '../keys/key.pem'), + sslCert: join(__dirname, '../keys/cert.pem'), + forceUser: 'https://ruben.verborgh.org/profile/#me' + }) + }) + + describe('MS-Author-Via', () => { // deprecated + describeHeaderTest('read/append for the public', { + resource: '/public-ra', + headers: { + 'MS-Author-Via': 'SPARQL', + 'Access-Control-Expose-Headers': /(^|,\s*)MS-Author-Via(,|$)/ + } + }) + }) + + describe('Accept-* for a resource document', () => { + describeHeaderTest('read/append for the public', { + resource: '/public-ra', + headers: { + 'Accept-Patch': 'text/n3, application/sparql-update, application/sparql-update-single-match', + 'Accept-Post': '*/*', + 'Accept-Put': '*/*', + 'Access-Control-Expose-Headers': /(^|,\s*)Accept-Patch, Accept-Post, Accept-Put(,|$)/ + } + }) + }) + + describe('WAC-Allow', () => { + describeHeaderTest('read/append for the public', { + resource: '/public-ra', + headers: { + 'WAC-Allow': 'user="read append",public="read append"', + 'Access-Control-Expose-Headers': /(^|,\s*)WAC-Allow(,|$)/ + } + }) + + describeHeaderTest('read/write for the user, read for the public', { + resource: '/user-rw-public-r', + headers: { + 'WAC-Allow': 'user="read write append",public="read"', + 'Access-Control-Expose-Headers': /(^|,\s*)WAC-Allow(,|$)/ + } + }) + + // FIXME: https://github.com/solid/node-solid-server/issues/1502 + describeHeaderTest('read/write/append/control for the user, nothing for the public', { + resource: '/user-rwac-public-0', + headers: { + 'WAC-Allow': 'user="read write append control",public=""', + 'Access-Control-Expose-Headers': /(^|,\s*)WAC-Allow(,|$)/ + } + }) + }) + + function describeHeaderTest (label, { resource, headers }) { + describe(`a resource that is ${label}`, () => { + // Retrieve the response headers + const response = {} + before(async function () { + this.timeout(10000) // FIXME: https://github.com/solid/node-solid-server/issues/1443 + const { headers } = await request.get(resource) + response.headers = headers + }) + + // Assert the existence of each of the expected headers + for (const header in headers) { + assertResponseHasHeader(response, header, headers[header]) + } + }) + } + + function assertResponseHasHeader (response, name, value) { + const key = name.toLowerCase() + if (value instanceof RegExp) { + it(`has a ${name} header matching ${value}`, () => { + expect(response.headers).to.have.property(key) + expect(response.headers[key]).to.match(value) + }) + } else { + it(`has a ${name} header of ${value}`, () => { + expect(response.headers).to.have.property(key, value) + }) + } + } +}) diff --git a/test/integration/http-copy-test.mjs b/test/integration/http-copy-test.js similarity index 94% rename from test/integration/http-copy-test.mjs rename to test/integration/http-copy-test.js index 13f75f836..59f04a1e5 100644 --- a/test/integration/http-copy-test.mjs +++ b/test/integration/http-copy-test.js @@ -1,109 +1,109 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import fs from 'fs' -import chai from 'chai' - -// Import utility functions from the ESM utils -import { httpRequest as request, rm } from '../utils.mjs' -import ldnode from '../../index.mjs' -const solidServer = ldnode.default || ldnode - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const { assert } = chai - -describe('HTTP COPY API', function () { - this.timeout(10000) // Set timeout for this test suite to 10 seconds - - const address = 'https://localhost:8443' - - let ldpHttpsServer - const ldp = solidServer.createServer({ - root: path.join(__dirname, '../resources/accounts/localhost/'), - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), - serverUri: 'https://localhost:8443', - webid: false - }) - - before(function (done) { - ldpHttpsServer = ldp.listen(8443, done) - }) - - after(function () { - if (ldpHttpsServer) ldpHttpsServer.close() - // Clean up after COPY API tests - return Promise.all([ - rm('/accounts/localhost/sampleUser1Container/nicola-copy.jpg') - ]) - }) - - const userCredentials = { - user1: { - cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) - }, - user2: { - cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), - key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) - } - } - - function createOptions (method, url, user) { - const options = { - method: method, - url: url, - headers: {} - } - if (user) { - options.agentOptions = userCredentials[user] - } - return options - } - - it('should create the copied resource', function (done) { - const copyFrom = '/samplePublicContainer/nicola.jpg' - const copyTo = '/sampleUser1Container/nicola-copy.jpg' - const uri = address + copyTo - const options = createOptions('COPY', uri, 'user1') - options.headers.Source = copyFrom - request(uri, options, function (error, response, body) { - if (error) { - return done(error) - } - assert.equal(response.statusCode, 201) - assert.equal(response.headers.location, copyTo) - const destinationPath = path.join(__dirname, '../resources/accounts/localhost', copyTo) - assert.ok(fs.existsSync(destinationPath), - 'Resource created via COPY should exist') - done() - }) - }) - - it('should give a 404 if source document doesn\'t exist', function (done) { - const copyFrom = '/samplePublicContainer/invalid-resource' - const copyTo = '/sampleUser1Container/invalid-resource-copy' - const uri = address + copyTo - const options = createOptions('COPY', uri, 'user1') - options.headers.Source = copyFrom - request(uri, options, function (error, response) { - if (error) { - return done(error) - } - assert.equal(response.statusCode, 404) - done() - }) - }) - - it('should give a 400 if Source header is not supplied', function (done) { - const copyTo = '/sampleUser1Container/nicola-copy.jpg' - const uri = address + copyTo - const options = createOptions('COPY', uri, 'user1') - request(uri, options, function (error, response) { - assert.equal(error, null) - assert.equal(response.statusCode, 400) - done() - }) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import fs from 'fs' +import chai from 'chai' + +// Import utility functions from the ESM utils +import { httpRequest as request, rm } from '../utils.js' +import ldnode from '../../index.js' +const solidServer = ldnode.default || ldnode + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const { assert } = chai + +describe('HTTP COPY API', function () { + this.timeout(10000) // Set timeout for this test suite to 10 seconds + + const address = 'https://localhost:8443' + + let ldpHttpsServer + const ldp = solidServer.createServer({ + root: path.join(__dirname, '../resources/accounts/localhost/'), + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + serverUri: 'https://localhost:8443', + webid: false + }) + + before(function (done) { + ldpHttpsServer = ldp.listen(8443, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + // Clean up after COPY API tests + return Promise.all([ + rm('/accounts/localhost/sampleUser1Container/nicola-copy.jpg') + ]) + }) + + const userCredentials = { + user1: { + cert: fs.readFileSync(path.join(__dirname, '../keys/user1-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user1-key.pem')) + }, + user2: { + cert: fs.readFileSync(path.join(__dirname, '../keys/user2-cert.pem')), + key: fs.readFileSync(path.join(__dirname, '../keys/user2-key.pem')) + } + } + + function createOptions (method, url, user) { + const options = { + method: method, + url: url, + headers: {} + } + if (user) { + options.agentOptions = userCredentials[user] + } + return options + } + + it('should create the copied resource', function (done) { + const copyFrom = '/samplePublicContainer/nicola.jpg' + const copyTo = '/sampleUser1Container/nicola-copy.jpg' + const uri = address + copyTo + const options = createOptions('COPY', uri, 'user1') + options.headers.Source = copyFrom + request(uri, options, function (error, response, body) { + if (error) { + return done(error) + } + assert.equal(response.statusCode, 201) + assert.equal(response.headers.location, copyTo) + const destinationPath = path.join(__dirname, '../resources/accounts/localhost', copyTo) + assert.ok(fs.existsSync(destinationPath), + 'Resource created via COPY should exist') + done() + }) + }) + + it('should give a 404 if source document doesn\'t exist', function (done) { + const copyFrom = '/samplePublicContainer/invalid-resource' + const copyTo = '/sampleUser1Container/invalid-resource-copy' + const uri = address + copyTo + const options = createOptions('COPY', uri, 'user1') + options.headers.Source = copyFrom + request(uri, options, function (error, response) { + if (error) { + return done(error) + } + assert.equal(response.statusCode, 404) + done() + }) + }) + + it('should give a 400 if Source header is not supplied', function (done) { + const copyTo = '/sampleUser1Container/nicola-copy.jpg' + const uri = address + copyTo + const options = createOptions('COPY', uri, 'user1') + request(uri, options, function (error, response) { + assert.equal(error, null) + assert.equal(response.statusCode, 400) + done() + }) + }) +}) diff --git a/test/integration/http-test.mjs b/test/integration/http-test.js similarity index 97% rename from test/integration/http-test.mjs rename to test/integration/http-test.js index cac2c886f..75b2b0637 100644 --- a/test/integration/http-test.mjs +++ b/test/integration/http-test.js @@ -1,1197 +1,1197 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import fs from 'fs' -import li from 'li' -import rdf from 'rdflib' -import { setupSupertestServer, rm } from '../utils.mjs' -import { assert, expect } from 'chai' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const suffixAcl = '.acl' -const suffixMeta = '.meta' -const server = setupSupertestServer({ - live: true, - dataBrowserPath: 'default', - root: path.join(__dirname, '../resources'), - auth: 'oidc', - webid: false -}) - -/** - * Creates a new turtle test resource via an LDP PUT - * (located in `test-esm/resources/{resourceName}`) - * @method createTestResource - * @param resourceName {String} Resource name (should have a leading `/`) - * @return {Promise} Promise obj, for use with Mocha's `before()` etc - */ -function createTestResource (resourceName) { - return new Promise(function (resolve, reject) { - server.put(resourceName) - .set('content-type', 'text/turtle') - .end(function (error, res) { - error ? reject(error) : resolve(res) - }) - }) -} - -describe('HTTP APIs', function () { - const emptyResponse = function (res) { - if (res.text) { - throw new Error('Not empty response') - } - } - const getLink = function (res, rel) { - if (res.headers.link) { - const links = res.headers.link.split(',') - for (const i in links) { - const link = links[i] - const parsedLink = li.parse(link) - if (parsedLink[rel]) { - return parsedLink[rel] - } - } - } - return undefined - } - const hasHeader = function (rel, value) { - const handler = function (res) { - const link = getLink(res, rel) - if (link) { - if (link !== value) { - throw new Error('Not same value: ' + value + ' != ' + link) - } - } else { - throw new Error('header does not exist: ' + rel + ' = ' + value) - } - } - return handler - } - - describe('GET Root container', function () { - it('should exist', function (done) { - server.get('/') - .expect(200, done) - }) - it('should be a turtle file by default', function (done) { - server.get('/') - .expect('content-type', /text\/turtle/) - .expect(200, done) - }) - it('should contain space:Storage triple', function (done) { - server.get('/') - .expect('content-type', /text\/turtle/) - .expect(200, done) - .expect((res) => { - const turtle = res.text - assert.match(turtle, /space:Storage/) - const kb = rdf.graph() - rdf.parse(turtle, kb, 'https://localhost/', 'text/turtle') - - assert(kb.match(undefined, - rdf.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), - rdf.namedNode('http://www.w3.org/ns/pim/space#Storage') - ).length, 'Must contain a triple space:Storage') - }) - }) - it('should have set Link as Container/BasicContainer/Storage', function (done) { - server.get('/') - .expect('content-type', /text\/turtle/) - .expect('Link', /; rel="type"/) - .expect('Link', /; rel="type"/) - .expect('Link', /; rel="type"/) - .expect(200, done) - }) - }) - - describe('OPTIONS API', function () { - it('should set the proper CORS headers', - function (done) { - server.options('/') - .set('Origin', 'http://example.com') - .expect('Access-Control-Allow-Origin', 'http://example.com') - .expect('Access-Control-Allow-Credentials', 'true') - .expect('Access-Control-Allow-Methods', 'OPTIONS,HEAD,GET,PATCH,POST,PUT,DELETE') - .expect('Access-Control-Expose-Headers', 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Accept-Put, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate, MS-Author-Via, X-Powered-By') - .expect(204, done) - }) - - describe('Accept-* headers', function () { - it('should be present for resources', function (done) { - server.options('/sampleContainer/example1.ttl') - .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') - .expect('Accept-Post', '*/*') - .expect('Accept-Put', '*/*') - .expect(204, done) - }) - - it('should be present for containers', function (done) { - server.options('/sampleContainer/') - .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') - .expect('Accept-Post', '*/*') - .expect('Accept-Put', '*/*') - .expect(204, done) - }) - - it('should be present for non-rdf resources', function (done) { - server.options('/sampleContainer/solid.png') - .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') - .expect('Accept-Post', '*/*') - .expect('Accept-Put', '*/*') - .expect(204, done) - }) - }) - - it('should have an empty response', function (done) { - server.options('/sampleContainer/example1.ttl') - .expect(emptyResponse) - .end(done) - }) - - it('should return 204 on success', function (done) { - server.options('/sampleContainer2/example1.ttl') - .expect(204) - .end(done) - }) - - it('should have Access-Control-Allow-Origin', function (done) { - server.options('/sampleContainer2/example1.ttl') - .set('Origin', 'http://example.com') - .expect('Access-Control-Allow-Origin', 'http://example.com') - .end(done) - }) - - it('should have set acl and describedBy Links for resource', - function (done) { - server.options('/sampleContainer2/example1.ttl') - .expect(hasHeader('acl', 'example1.ttl' + suffixAcl)) - .expect(hasHeader('describedBy', 'example1.ttl' + suffixMeta)) - .end(done) - }) - - it('should have set Link as resource', function (done) { - server.options('/sampleContainer2/example1.ttl') - .expect('Link', /; rel="type"/) - .end(done) - }) - - it('should have set Link as Container/BasicContainer on an implicit index page', function (done) { - server.options('/sampleContainer/') - .expect('Link', /; rel="type"/) - .expect('Link', /; rel="type"/) - .end(done) - }) - - it('should have set Link as Container/BasicContainer', function (done) { - server.options('/sampleContainer2/') - .set('Origin', 'http://example.com') - .expect('Link', /; rel="type"/) - .expect('Link', /; rel="type"/) - .end(done) - }) - - it('should have set Accept-Post for containers', function (done) { - server.options('/sampleContainer2/') - .set('Origin', 'http://example.com') - .expect('Accept-Post', '*/*') - .end(done) - }) - - it('should have set acl and describedBy Links for container', function (done) { - server.options('/sampleContainer2/') - .expect(hasHeader('acl', suffixAcl)) - .expect(hasHeader('describedBy', suffixMeta)) - .end(done) - }) - }) - - describe('Not allowed method should return 405 and allow header', function (done) { - it('TRACE should return 405', function (done) { - server.trace('/sampleContainer2/') - // .expect(hasHeader('allow', 'OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE')) - .expect(405) - .end((err, res) => { - if (err) done(err) - const allow = res.headers.allow - console.log(allow) - if (allow === 'OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE') done() - else done(new Error('no allow header')) - }) - }) - }) - - describe('GET API', function () { - it('should have the same size of the file on disk', function (done) { - server.get('/sampleContainer/solid.png') - .expect(200) - .end(function (err, res) { - if (err) { - return done(err) - } - - const size = fs.statSync(path.join(__dirname, - '../resources/sampleContainer/solid.png')).size - if (res.body.length !== size) { - return done(new Error('files are not of the same size')) - } - done() - }) - }) - - it('should have Access-Control-Allow-Origin as Origin on containers', function (done) { - server.get('/sampleContainer2/') - .set('Origin', 'http://example.com') - .expect('content-type', /text\/turtle/) - .expect('Access-Control-Allow-Origin', 'http://example.com') - .expect(200, done) - }) - it('should have Access-Control-Allow-Origin as Origin on resources', - function (done) { - server.get('/sampleContainer2/example1.ttl') - .set('Origin', 'http://example.com') - .expect('content-type', /text\/turtle/) - .expect('Access-Control-Allow-Origin', 'http://example.com') - .expect(200, done) - }) - it('should have set Link as resource', function (done) { - server.get('/sampleContainer2/example1.ttl') - .expect('content-type', /text\/turtle/) - .expect('Link', /; rel="type"/) - .expect(200, done) - }) - it('should have set Updates-Via to use WebSockets', function (done) { - server.get('/sampleContainer2/example1.ttl') - .expect('updates-via', /wss?:\/\//) - .expect(200, done) - }) - it('should have set acl and describedBy Links for resource', - function (done) { - server.get('/sampleContainer2/example1.ttl') - .expect('content-type', /text\/turtle/) - .expect(hasHeader('acl', 'example1.ttl' + suffixAcl)) - .expect(hasHeader('describedBy', 'example1.ttl' + suffixMeta)) - .end(done) - }) - it('should have set Link as Container/BasicContainer', function (done) { - server.get('/sampleContainer2/') - .expect('content-type', /text\/turtle/) - .expect('Link', /; rel="type"/) - .expect('Link', /; rel="type"/) - .expect(200, done) - }) - it('should load skin (mashlib) if resource was requested as text/html', function (done) { - server.get('/sampleContainer2/example1.ttl') - .set('Accept', 'text/html') - .expect('content-type', /text\/html/) - .expect(function (res) { - if (res.text.indexOf('TabulatorOutline') < 0) { - throw new Error('did not load the Tabulator skin by default') - } - }) - .expect(200, done) // Can't check for 303 because of internal redirects - }) - it('should NOT load data browser (mashlib) if resource is not RDF', function (done) { - server.get('/sampleContainer/solid.png') - .set('Accept', 'text/html') - .expect('content-type', /image\/png/) - .expect(200, done) - }) - - it('should NOT load data browser (mashlib) if a resource has an .html extension', function (done) { - server.get('/sampleContainer/index.html') - .set('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8') - .expect('content-type', /text\/html/) - .expect(200) - .expect((res) => { - if (res.text.includes('TabulatorOutline')) { - throw new Error('Loaded data browser though resource has an .html extension') - } - }) - .end(done) - }) - - it('should NOT load data browser (mashlib) if directory has an index file', function (done) { - server.get('/sampleContainer/') - .set('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8') - .expect('content-type', /text\/html/) - .expect(200) - .expect((res) => { - if (res.text.includes('TabulatorOutline')) { - throw new Error('Loaded data browser though resource has an .html extension') - } - }) - .end(done) - }) - - it('should show data browser if container was requested as text/html', function (done) { - server.get('/sampleContainer2/') - .set('Accept', 'text/html') - .expect('content-type', /text\/html/) - .expect(200, done) - }) - it('should redirect to the right container URI if missing /', function (done) { - server.get('/sampleContainer') - .expect(301, done) - }) - it('should return 404 for non-existent resource', function (done) { - server.get('/invalidfile.foo') - .expect(404, done) - }) - it('should return 404 for non-existent container', function (done) { - server.get('/inexistant/') - .expect('Accept-Put', 'text/turtle') - .expect(404, done) - }) - it('should return basic container link for directories', function (done) { - server.get('/') - .expect('Link', /http:\/\/www.w3.org\/ns\/ldp#BasicContainer/) - .expect('content-type', /text\/turtle/) - .expect(200, done) - }) - it('should return resource link for files', function (done) { - server.get('/hello.html') - .expect('Link', /; rel="type"/) - .expect('Content-Type', /text\/html/) - .expect(200, done) - }) - it('should have glob support', function (done) { - server.get('/sampleContainer/*') - .expect('content-type', /text\/turtle/) - .expect(200) - .expect((res) => { - const kb = rdf.graph() - rdf.parse(res.text, kb, 'https://localhost/', 'text/turtle') - - assert(kb.match( - rdf.namedNode('https://localhost/example1.ttl#this'), - rdf.namedNode('http://purl.org/dc/elements/1.1/title'), - rdf.literal('Test title') - ).length, 'Must contain a triple from example1.ttl') - - assert(kb.match( - rdf.namedNode('http://example.org/stuff/1.0/a'), - rdf.namedNode('http://example.org/stuff/1.0/b'), - rdf.literal('apple') - ).length, 'Must contain a triple from example2.ttl') - - assert(kb.match( - rdf.namedNode('http://example.org/stuff/1.0/a'), - rdf.namedNode('http://example.org/stuff/1.0/b'), - rdf.literal('The first line\nThe second line\n more') - ).length, 'Must contain a triple from example3.ttl') - }) - .end(done) - }) - it('should have set acl and describedBy Links for container', - function (done) { - server.get('/sampleContainer2/') - .expect(hasHeader('acl', suffixAcl)) - .expect(hasHeader('describedBy', suffixMeta)) - .expect('content-type', /text\/turtle/) - .end(done) - }) - it('should return requested index.html resource by default', function (done) { - server.get('/sampleContainer/index.html') - .set('accept', 'text/html') - .expect(200) - .expect('content-type', /text\/html/) - .expect(function (res) { - if (res.text.indexOf('') < 0) { - throw new Error('wrong content returned for index.html') - } - }) - .end(done) - }) - it('should fallback on index.html if it exists and content-type is given', - function (done) { - server.get('/sampleContainer/') - .set('accept', 'text/html') - .expect(200) - .expect('content-type', /text\/html/) - .end(done) - }) - it('should return turtle if requesting a conatiner that has index.html with conteent-type text/turtle', (done) => { - server.get('/sampleContainer/') - .set('accept', 'text/turtle') - .expect(200) - .expect('content-type', /text\/turtle/) - .end(done) - }) - it('should return turtle if requesting a container that conatins an index.html file with a content type where some rdf format is ranked higher than html', (done) => { - server.get('/sampleContainer/') - .set('accept', 'image/*;q=0.9, */*;q=0.1, application/rdf+xml;q=0.9, application/xhtml+xml, text/xml;q=0.5, application/xml;q=0.5, text/html;q=0.9, text/plain;q=0.5, text/n3;q=1.0, text/turtle;q=1') - .expect(200) - .expect('content-type', /text\/turtle/) - .end(done) - }) - it('should still redirect to the right container URI if missing / and HTML is requested', function (done) { - server.get('/sampleContainer') - .set('accept', 'text/html') - .expect('location', /\/sampleContainer\//) - .expect(301, done) - }) - - describe('Accept-* headers', function () { - it('should return 404 for non-existent resource', function (done) { - server.get('/invalidfile.foo') - .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') - .expect('Accept-Post', '*/*') - .expect('Accept-put', '*/*') - .expect(404, done) - }) - it('Accept-Put=text/turtle for non-existent container', function (done) { - server.get('/inexistant/') - .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') - .expect('Accept-Post', '*/*') - .expect('Accept-Put', 'text/turtle') - .expect(404, done) - }) - it('Accept-Put header do not exist for existing container', (done) => { - server.get('/sampleContainer/') - .expect(200) - .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') - .expect('Accept-Post', '*/*') - .expect((res) => { - if (res.headers['Accept-Put']) return done(new Error('Accept-Put header should not exist')) - }) - .end(done) - }) - }) - }) - - describe('HEAD API', function () { - it('should return content-type application/octet-stream by default', function (done) { - server.head('/sampleContainer/blank') - .expect('Content-Type', /application\/octet-stream/) - .end(done) - }) - it('should return content-type text/turtle for container', function (done) { - server.head('/sampleContainer2/') - .expect('Content-Type', /text\/turtle/) - .end(done) - }) - it('should have set content-type for turtle files', - function (done) { - server.head('/sampleContainer2/example1.ttl') - .expect('Content-Type', /text\/turtle/) - .end(done) - }) - it('should have set content-type for implicit turtle files', - function (done) { - server.head('/sampleContainer/example4') - .expect('Content-Type', /text\/turtle/) - .end(done) - }) - it('should have set content-type for image files', - function (done) { - server.head('/sampleContainer/solid.png') - .expect('Content-Type', /image\/png/) - .end(done) - }) - it('should have Access-Control-Allow-Origin as Origin', function (done) { - server.head('/sampleContainer2/example1.ttl') - .set('Origin', 'http://example.com') - .expect('Access-Control-Allow-Origin', 'http://example.com') - .expect(200, done) - }) - it('should return empty response body', function (done) { - server.head('/patch-5-initial.ttl') - .expect(emptyResponse) - .expect(200, done) - }) - it('should have set Updates-Via to use WebSockets', function (done) { - server.head('/sampleContainer2/example1.ttl') - .expect('updates-via', /wss?:\/\//) - .expect(200, done) - }) - it('should have set Link as Resource', function (done) { - server.head('/sampleContainer2/example1.ttl') - .expect('Link', /; rel="type"/) - .expect(200, done) - }) - it('should have set acl and describedBy Links for resource', - function (done) { - server.head('/sampleContainer2/example1.ttl') - .expect(hasHeader('acl', 'example1.ttl' + suffixAcl)) - .expect(hasHeader('describedBy', 'example1.ttl' + suffixMeta)) - .end(done) - }) - it('should have set Content-Type as text/turtle for Container', - function (done) { - server.head('/sampleContainer2/') - .expect('Content-Type', /text\/turtle/) - .expect(200, done) - }) - it('should have set Link as Container/BasicContainer', - function (done) { - server.head('/sampleContainer2/') - .expect('Link', /; rel="type"/) - .expect('Link', /; rel="type"/) - .expect(200, done) - }) - it('should have set acl and describedBy Links for container', - function (done) { - server.head('/sampleContainer2/') - .expect(hasHeader('acl', suffixAcl)) - .expect(hasHeader('describedBy', suffixMeta)) - .end(done) - }) - }) - - describe('PUT API', function () { - const putRequestBody = fs.readFileSync(path.join(__dirname, - '../resources/sampleContainer/put1.ttl'), { - encoding: 'utf8' - }) - it('should create new resource with if-none-match on non existing resource', function (done) { - server.put('/put-resource-1.ttl') - .send(putRequestBody) - .set('if-none-match', '*') - .set('content-type', 'text/plain') - .expect(201, done) - }) - it('should fail with 412 with precondition on existing resource', function (done) { - server.put('/put-resource-1.ttl') - .send(putRequestBody) - .set('if-none-match', '*') - .set('content-type', 'text/plain') - .expect(412, done) - }) - it('should fail with 400 if not content-type', function (done) { - server.put('/put-resource-1.ttl') - .send(putRequestBody) - .set('content-type', '') - .expect(400, done) - }) - it('should create new resource and delete old path if different', function (done) { - server.put('/put-resource-1.ttl') - .send(putRequestBody) - .set('content-type', 'text/turtle') - .expect(204) - .end(function (err) { - if (err) return done(err) - if (fs.existsSync(path.join(__dirname, '../resources/put-resource-1.ttl$.txt'))) { - return done(new Error('Can read old file that should have been deleted')) - } - done() - }) - }) - it('should reject create .acl resource, if contentType not text/turtle', function (done) { - server.put('/put-resource-1.acl') - .send(putRequestBody) - .set('content-type', 'text/plain') - .expect(415, done) - }) - it('should reject create .acl resource, if body is not valid turtle', function (done) { - server.put('/put-resource-1.acl') - .send('bad turtle content') - .set('content-type', 'text/turtle') - .expect(400, done) - }) - it('should reject create .meta resource, if contentType not text/turtle', function (done) { - server.put('/.meta') - .send(putRequestBody) - .set('content-type', 'text/plain') - .expect(415, done) - }) - it('should reject create .meta resource, if body is not valid turtle', function (done) { - server.put('/.meta') - .send(JSON.stringify({})) - .set('content-type', 'text/turtle') - .expect(400, done) - }) - it('should create directories if they do not exist', function (done) { - server.put('/foo/bar/baz.ttl') - .send(putRequestBody) - .set('content-type', 'text/turtle') - .expect(hasHeader('describedBy', 'baz.ttl' + suffixMeta)) - .expect(hasHeader('acl', 'baz.ttl' + suffixAcl)) - .expect(201, done) - }) - it('should not create a resource with percent-encoded $.ext', function (done) { - server.put('/foo/bar/baz%24.ttl') - .send(putRequestBody) - .set('content-type', 'text/turtle') - // .expect(hasHeader('describedBy', 'baz.ttl' + suffixMeta)) - // .expect(hasHeader('acl', 'baz.ttl' + suffixAcl)) - .expect(400, done) // 404 - }) - it('should create a resource without extension', function (done) { - server.put('/foo/bar/baz') - .send(putRequestBody) - .set('content-type', 'text/turtle') - .expect(hasHeader('describedBy', 'baz' + suffixMeta)) - .expect(hasHeader('acl', 'baz' + suffixAcl)) - .expect(201, done) - }) - it('should not create a container if a document with same name exists in tree', function (done) { - server.put('/foo/bar/baz/') - .send(putRequestBody) - // .set('content-type', 'text/turtle') - // .expect(hasHeader('describedBy', suffixMeta)) - // .expect(hasHeader('acl', suffixAcl)) - .expect(409, done) - }) - it('should not create new resource if a folder/resource with same name will exist in tree', function (done) { - server.put('/foo/bar/baz/baz1/test.ttl') - .send(putRequestBody) - .set('content-type', 'text/turtle') - .expect(hasHeader('describedBy', 'test.ttl' + suffixMeta)) - .expect(hasHeader('acl', 'test.ttl' + suffixAcl)) - .expect(409, done) - }) - it('should return 201 when trying to put to a container without content-type', - function (done) { - server.put('/foo/bar/test/') - // .set('content-type', 'text/turtle') - .set('link', '; rel="type"') - .expect(201, done) - } - ) - it('should return 204 code when trying to put to a container', - function (done) { - server.put('/foo/bar/test/') - .set('content-type', 'text/turtle') - .set('link', '; rel="type"') - .expect(204, done) - } - ) - it('should return 204 when trying to put to a container without content-type', - function (done) { - server.put('/foo/bar/test/') - // .set('content-type', 'text/turtle') - .set('link', '; rel="type"') - .expect(204, done) - } - ) - it('should return 204 code when trying to put to a container', - function (done) { - server.put('/foo/bar/test/') - .set('content-type', 'text/turtle') - .set('link', '; rel="type"') - .expect(204, done) - } - ) - it('should return a 400 error when trying to PUT a container with a name that contains a reserved suffix', - function (done) { - server.put('/foo/bar.acl/test/') - .set('content-type', 'text/turtle') - .set('link', '; rel="type"') - .expect(400, done) - } - ) - it('should return a 400 error when trying to PUT a resource with a name that contains a reserved suffix', - function (done) { - server.put('/foo/bar.acl/test.ttl') - .send(putRequestBody) - .set('content-type', 'text/turtle') - .set('link', '; rel="type"') - .expect(400, done) - } - ) - // Cleanup - after(function () { - rm('/foo/') - }) - }) - - describe('DELETE API', function () { - before(function () { - // Ensure all these are finished before running tests - return Promise.all([ - rm('/false-file-48484848'), - createTestResource('/.acl'), - createTestResource('/profile/card'), - createTestResource('/delete-test-empty-container/.meta.acl'), - createTestResource('/put-resource-1.ttl'), - createTestResource('/put-resource-with-acl.ttl'), - createTestResource('/put-resource-with-acl.ttl.acl'), - createTestResource('/put-resource-with-acl.txt'), - createTestResource('/put-resource-with-acl.txt.acl'), - createTestResource('/delete-test-non-empty/test.ttl') - ]) - }) - - it('should return 405 status when deleting root folder', function (done) { - server.delete('/') - .expect(405) - .end((err, res) => { - if (err) return done(err) - try { - assert.equal(res.get('allow').includes('DELETE'), false) - } catch (err) { - return done(err) - } - done() - }) - }) - - it('should return 405 status when deleting root acl', function (done) { - server.delete('/' + suffixAcl) - .expect(405) - .end((err, res) => { - if (err) return done(err) - try { - assert.equal(res.get('allow').includes('DELETE'), false) // ,'res methods') - } catch (err) { - return done(err) - } - done() - }) - }) - - it('should return 405 status when deleting /profile/card', function (done) { - server.delete('/profile/card') - .expect(405) - .end((err, res) => { - if (err) return done(err) - try { - assert.equal(res.get('allow').includes('DELETE'), false) // ,'res methods') - } catch (err) { - return done(err) - } - done() - }) - }) - - it('should return 404 status when deleting a file that does not exists', - function (done) { - server.delete('/false-file-48484848') - .expect(404, done) - }) - - it('should delete previously PUT file', function (done) { - server.delete('/put-resource-1.ttl') - .expect(200, done) - }) - - it('should delete previously PUT file with ACL', function (done) { - server.delete('/put-resource-with-acl.ttl') - .expect(200, done) - }) - - it('should return 404 on deleting .acl of previously deleted PUT file with ACL', function (done) { - server.delete('/put-resource-with-acl.ttl.acl') - .expect(404, done) - }) - - it('should delete previously PUT file with bad extension and with ACL', function (done) { - server.delete('/put-resource-with-acl.txt') - .expect(200, done) - }) - - it('should return 404 on deleting .acl of previously deleted PUT file with bad extension and with ACL', function (done) { - server.delete('/put-resource-with-acl.txt.acl') - .expect(404, done) - }) - - it('should fail to delete non-empty containers', function (done) { - server.delete('/delete-test-non-empty/') - .expect(409, done) - }) - - it('should delete a new and empty container - with .meta.acl', function (done) { - server.delete('/delete-test-empty-container/') - .end(() => { - server.get('/delete-test-empty-container/') - .expect(404) - .end(done) - }) - }) - - after(function () { - // Clean up after DELETE API tests - rm('/profile/') - rm('/put-resource-1.ttl') - rm('/delete-test-non-empty/') - rm('/delete-test-empty-container/test.txt.acl') - rm('/delete-test-empty-container/') - }) - }) - - describe('POST API', function () { - let postLocation - before(function () { - // Ensure all these are finished before running tests - return Promise.all([ - createTestResource('/post-tests/put-resource'), - // createTestContainer('post-tests'), - rm('post-test-target.ttl') // , - // createTestResource('/post-tests/put-resource') - ]) - }) - - const postRequest1Body = fs.readFileSync(path.join(__dirname, - '../resources/sampleContainer/put1.ttl'), { - encoding: 'utf8' - }) - const postRequest2Body = fs.readFileSync(path.join(__dirname, - '../resources/sampleContainer/post2.ttl'), { - encoding: 'utf8' - }) - // Capture the resource name generated by server by parsing Location: header - let postedResourceName - const getResourceName = function (res) { - postedResourceName = res.header.location - } - - it('should create new document resource', function (done) { - server.post('/post-tests/') - .send(postRequest1Body) - .set('content-type', 'text/turtle') - .set('slug', 'post-resource-1') - .expect('location', /\/post-resource-1/) - .expect(hasHeader('describedBy', suffixMeta)) - .expect(hasHeader('acl', suffixAcl)) - .expect(201, done) - }) - it('should create new resource even if body is empty', function (done) { - server.post('/post-tests/') - .set('slug', 'post-resource-empty') - .set('content-type', 'text/turtle') - .expect(hasHeader('describedBy', suffixMeta)) - .expect(hasHeader('acl', suffixAcl)) - .expect('location', /.*\.ttl/) - .expect(201, done) - }) - it('should create container with new slug as a resource', function (done) { - server.post('/post-tests/') - .set('content-type', 'text/turtle') - .set('slug', 'put-resource') - .set('link', '; rel="type"') - .send(postRequest2Body) - .expect(201) - .end((err, res) => { - if (err) return done(err) - try { - postLocation = res.headers.location - // console.log('location ' + postLocation) - const createdDir = fs.statSync(path.join(__dirname, '../resources', postLocation.slice(0, -1))) - assert(createdDir.isDirectory(), 'Container should have been created') - } catch (err) { - return done(err) - } - done() - }) - }) - it('should get newly created container with new slug', function (done) { - console.log('location' + postLocation) - server.get(postLocation) - .expect(200, done) - }) - it('should error with 403 if auxiliary resource file.acl', function (done) { - server.post('/post-tests/') - .set('slug', 'post-acl-no-content-type.acl') - .send(postRequest1Body) - .set('content-type', 'text/turtle') - .expect(403, done) - }) - it('should error with 403 if auxiliary resource .meta', function (done) { - server.post('/post-tests/') - .set('slug', '.meta') - .send(postRequest1Body) - .set('content-type', 'text/turtle') - .expect(403, done) - }) - it('should error with 400 if the body is empty and no content type is provided', function (done) { - server.post('/post-tests/') - .set('slug', 'post-resource-empty-fail') - .expect(400, done) - }) - it('should error with 400 if the body is provided but there is no content-type header', function (done) { - server.post('/post-tests/') - .set('slug', 'post-resource-rdf-no-content-type') - .send(postRequest1Body) - .set('content-type', '') - .expect(400, done) - }) - it('should create new resource even if no trailing / is in the target', - function (done) { - server.post('') - .send(postRequest1Body) - .set('content-type', 'text/turtle') - .set('slug', 'post-test-target') - .expect('location', /\/post-test-target\.ttl/) - .expect(hasHeader('describedBy', suffixMeta)) - .expect(hasHeader('acl', suffixAcl)) - .expect(201, done) - }) - it('should create new resource even if slug contains invalid suffix', function (done) { - server.post('/post-tests/') - .set('slug', 'put-resource.acl.ttl') - .send(postRequest1Body) - .set('content-type', 'text-turtle') - .expect(hasHeader('describedBy', suffixMeta)) - .expect(hasHeader('acl', suffixAcl)) - .expect(201, done) - }) - it('create container with recursive example', function (done) { - server.post('/post-tests/') - .set('content-type', 'text/turtle') - .set('slug', 'foo.bar.acl.meta') - .set('link', '; rel="type"') - .send(postRequest2Body) - .expect('location', /\/post-tests\/foo.bar\//) - .expect(201, done) - }) - it('should fail return 404 if no parent container found', function (done) { - server.post('/hello.html/') - .send(postRequest1Body) - .set('content-type', 'text/turtle') - .set('slug', 'post-test-target2') - .expect(404, done) - }) - it('should create a new slug if there is a resource with the same name', - function (done) { - server.post('/post-tests/') - .send(postRequest1Body) - .set('content-type', 'text/turtle') - .set('slug', 'post-resource-1') - .expect(201, done) - }) - it('should be able to delete newly created resource', function (done) { - server.delete('/post-tests/post-resource-1.ttl') - .expect(200, done) - }) - it('should create new resource without slug header', function (done) { - server.post('/post-tests/') - .send(postRequest1Body) - .set('content-type', 'text/turtle') - .expect(201) - .expect(getResourceName) - .end(done) - }) - it('should be able to delete newly created resource (2)', function (done) { - server.delete('/' + - postedResourceName.replace(/https?:\/\/((127.0.0.1)|(localhost)):[0-9]*\//, '')) - .expect(200, done) - }) - it('should create container', function (done) { - server.post('/post-tests/') - .set('content-type', 'text/turtle') - .set('slug', 'loans.ttl') - .set('link', '; rel="type"') - .send(postRequest2Body) - .expect('location', /\/post-tests\/loans.ttl\//) - .expect(201) - .end((err, res) => { - if (err) return done(err) - try { - postLocation = res.headers.location - console.log('location ' + postLocation) - const createdDir = fs.statSync(path.join(__dirname, '../resources', postLocation.slice(0, -1))) - assert(createdDir.isDirectory(), 'Container should have been created') - } catch (err) { - return done(err) - } - done() - }) - }) - it('should be able to access newly container', function (done) { - console.log(postLocation) - server.get(postLocation) - // .expect('content-type', /text\/turtle/) - .expect(200, done) - }) - it('should create container', function (done) { - server.post('/post-tests/') - .set('content-type', 'text/turtle') - .set('slug', 'loans.acl.meta') - .set('link', '; rel="type"') - .send(postRequest2Body) - .expect('location', /\/post-tests\/loans\//) - .expect(201) - .end((err, res) => { - if (err) return done(err) - try { - postLocation = res.headers.location - assert(!postLocation.endsWith('.acl/') && !postLocation.endsWith('.meta/'), 'Container name cannot end with ".acl" or ".meta"') - } catch (err) { - return done(err) - } - done() - }) - }) - it('should be able to access newly created container', function (done) { - console.log(postLocation) - server.get(postLocation) - // .expect('content-type', /text\/turtle/) - .expect(200, done) - }) - it('should create a new slug if there is a container with same name', function (done) { - server.post('/post-tests/') - .send(postRequest1Body) - .set('content-type', 'text/turtle') - .set('slug', 'loans.ttl') - .expect(201) - .expect(getResourceName) - .end(done) - }) - it('should get newly created document resource with new slug', function (done) { - console.log(postedResourceName) - server.get(postedResourceName) - .expect(200, done) - }) - it('should create a container with a name hex decoded from the slug', (done) => { - const containerName = 'Film%4011' - const expectedDirName = '/post-tests/Film@11/' - server.post('/post-tests/') - .set('slug', containerName) - .set('content-type', 'text/turtle') - .set('link', '; rel="type"') - .expect(201) - .end((err, res) => { - if (err) return done(err) - try { - assert.equal(res.headers.location, expectedDirName, - 'Uri container names should be encoded') - const createdDir = fs.statSync(path.join(__dirname, '../resources', expectedDirName)) - assert(createdDir.isDirectory(), 'Container should have been created') - } catch (err) { - return done(err) - } - done() - }) - }) - - describe('content-type-based file extensions', () => { - // ensure the container exists - before(() => - server.post('/post-tests/') - .send(postRequest1Body) - .set('content-type', 'text/turtle') - ) - - describe('a new text/turtle document posted without slug', () => { - let response - before(() => - server.post('/post-tests/') - .set('content-type', 'text/turtle; charset=utf-8') - .then(res => { response = res }) - ) - - it('is assigned an URL with the .ttl extension', () => { - expect(response.headers).to.have.property('location') - expect(response.headers.location).to.match(/^\/post-tests\/[^./]+\.ttl$/) - }) - }) - - describe('a new text/turtle document posted with a slug', () => { - let response - before(() => - server.post('/post-tests/') - .set('slug', 'slug1') - .set('content-type', 'text/turtle; charset=utf-8') - .then(res => { response = res }) - ) - - it('is assigned an URL with the .ttl extension', () => { - expect(response.headers).to.have.property('location', '/post-tests/slug1.ttl') - }) - }) - - describe('a new text/html document posted without slug', () => { - let response - before(() => - server.post('/post-tests/') - .set('content-type', 'text/html; charset=utf-8') - .then(res => { response = res }) - ) - - it('is assigned an URL with the .html extension', () => { - expect(response.headers).to.have.property('location') - expect(response.headers.location).to.match(/^\/post-tests\/[^./]+\.html$/) - }) - }) - - describe('a new text/html document posted with a slug', () => { - let response - before(() => - server.post('/post-tests/') - .set('slug', 'slug2') - .set('content-type', 'text/html; charset=utf-8') - .then(res => { response = res }) - ) - - it('is assigned an URL with the .html extension', () => { - expect(response.headers).to.have.property('location', '/post-tests/slug2.html') - }) - }) - }) - - /* No, URLs are NOT ex-encoded to make filenames -- the other way around. - it('should create a container with a url name', (done) => { - let containerName = 'https://example.com/page' - let expectedDirName = '/post-tests/https%3A%2F%2Fexample.com%2Fpage/' - server.post('/post-tests/') - .set('slug', containerName) - .set('content-type', 'text/turtle') - .set('link', '; rel="type"') - .expect(201) - .end((err, res) => { - if (err) return done(err) - try { - assert.equal(res.headers.location, expectedDirName, - 'Uri container names should be encoded') - let createdDir = fs.statSync(path.join(__dirname, 'resources', expectedDirName)) - assert(createdDir.isDirectory(), 'Container should have been created') - } catch (err) { - return done(err) - } - done() - }) - }) - - it('should be able to access new url-named container', (done) => { - let containerUrl = '/post-tests/https%3A%2F%2Fexample.com%2Fpage/' - server.get(containerUrl) - .expect('content-type', /text\/turtle/) - .expect(200, done) - }) - */ - - after(function () { - // Clean up after POST API tests - return Promise.all([ - rm('/post-tests/put-resource'), - rm('/post-tests/'), - rm('post-test-target.ttl') - ]) - }) - }) - - describe('POST (multipart)', function () { - it('should create as many files as the ones passed in multipart', - function (done) { - server.post('/sampleContainer/') - .attach('timbl', path.join(__dirname, '../resources/timbl.jpg')) - .attach('nicola', path.join(__dirname, '../resources/nicola.jpg')) - .expect(200) - .end(function (err) { - if (err) return done(err) - - const sizeNicola = fs.statSync(path.join(__dirname, - '../resources/nicola.jpg')).size - const sizeTim = fs.statSync(path.join(__dirname, '../resources/timbl.jpg')).size - const sizeNicolaLocal = fs.statSync(path.join(__dirname, - '../resources/sampleContainer/nicola.jpg')).size - const sizeTimLocal = fs.statSync(path.join(__dirname, - '../resources/sampleContainer/timbl.jpg')).size - - if (sizeNicola === sizeNicolaLocal && sizeTim === sizeTimLocal) { - return done() - } else { - return done(new Error('Either the size (remote/local) don\'t match or files are not stored')) - } - }) - }) - after(function () { - // Clean up after POST (multipart) API tests - return Promise.all([ - rm('/sampleContainer/nicola.jpg'), - rm('/sampleContainer/timbl.jpg') - ]) - }) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import fs from 'fs' +import li from 'li' +import rdf from 'rdflib' +import { setupSupertestServer, rm } from '../utils.js' +import { assert, expect } from 'chai' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const suffixAcl = '.acl' +const suffixMeta = '.meta' +const server = setupSupertestServer({ + live: true, + dataBrowserPath: 'default', + root: path.join(__dirname, '../resources'), + auth: 'oidc', + webid: false +}) + +/** + * Creates a new turtle test resource via an LDP PUT + * (located in `test-esm/resources/{resourceName}`) + * @method createTestResource + * @param resourceName {String} Resource name (should have a leading `/`) + * @return {Promise} Promise obj, for use with Mocha's `before()` etc + */ +function createTestResource (resourceName) { + return new Promise(function (resolve, reject) { + server.put(resourceName) + .set('content-type', 'text/turtle') + .end(function (error, res) { + error ? reject(error) : resolve(res) + }) + }) +} + +describe('HTTP APIs', function () { + const emptyResponse = function (res) { + if (res.text) { + throw new Error('Not empty response') + } + } + const getLink = function (res, rel) { + if (res.headers.link) { + const links = res.headers.link.split(',') + for (const i in links) { + const link = links[i] + const parsedLink = li.parse(link) + if (parsedLink[rel]) { + return parsedLink[rel] + } + } + } + return undefined + } + const hasHeader = function (rel, value) { + const handler = function (res) { + const link = getLink(res, rel) + if (link) { + if (link !== value) { + throw new Error('Not same value: ' + value + ' != ' + link) + } + } else { + throw new Error('header does not exist: ' + rel + ' = ' + value) + } + } + return handler + } + + describe('GET Root container', function () { + it('should exist', function (done) { + server.get('/') + .expect(200, done) + }) + it('should be a turtle file by default', function (done) { + server.get('/') + .expect('content-type', /text\/turtle/) + .expect(200, done) + }) + it('should contain space:Storage triple', function (done) { + server.get('/') + .expect('content-type', /text\/turtle/) + .expect(200, done) + .expect((res) => { + const turtle = res.text + assert.match(turtle, /space:Storage/) + const kb = rdf.graph() + rdf.parse(turtle, kb, 'https://localhost/', 'text/turtle') + + assert(kb.match(undefined, + rdf.namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type'), + rdf.namedNode('http://www.w3.org/ns/pim/space#Storage') + ).length, 'Must contain a triple space:Storage') + }) + }) + it('should have set Link as Container/BasicContainer/Storage', function (done) { + server.get('/') + .expect('content-type', /text\/turtle/) + .expect('Link', /; rel="type"/) + .expect('Link', /; rel="type"/) + .expect('Link', /; rel="type"/) + .expect(200, done) + }) + }) + + describe('OPTIONS API', function () { + it('should set the proper CORS headers', + function (done) { + server.options('/') + .set('Origin', 'http://example.com') + .expect('Access-Control-Allow-Origin', 'http://example.com') + .expect('Access-Control-Allow-Credentials', 'true') + .expect('Access-Control-Allow-Methods', 'OPTIONS,HEAD,GET,PATCH,POST,PUT,DELETE') + .expect('Access-Control-Expose-Headers', 'Authorization, User, Location, Link, Vary, Last-Modified, ETag, Accept-Patch, Accept-Post, Accept-Put, Updates-Via, Allow, WAC-Allow, Content-Length, WWW-Authenticate, MS-Author-Via, X-Powered-By') + .expect(204, done) + }) + + describe('Accept-* headers', function () { + it('should be present for resources', function (done) { + server.options('/sampleContainer/example1.ttl') + .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') + .expect('Accept-Post', '*/*') + .expect('Accept-Put', '*/*') + .expect(204, done) + }) + + it('should be present for containers', function (done) { + server.options('/sampleContainer/') + .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') + .expect('Accept-Post', '*/*') + .expect('Accept-Put', '*/*') + .expect(204, done) + }) + + it('should be present for non-rdf resources', function (done) { + server.options('/sampleContainer/solid.png') + .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') + .expect('Accept-Post', '*/*') + .expect('Accept-Put', '*/*') + .expect(204, done) + }) + }) + + it('should have an empty response', function (done) { + server.options('/sampleContainer/example1.ttl') + .expect(emptyResponse) + .end(done) + }) + + it('should return 204 on success', function (done) { + server.options('/sampleContainer2/example1.ttl') + .expect(204) + .end(done) + }) + + it('should have Access-Control-Allow-Origin', function (done) { + server.options('/sampleContainer2/example1.ttl') + .set('Origin', 'http://example.com') + .expect('Access-Control-Allow-Origin', 'http://example.com') + .end(done) + }) + + it('should have set acl and describedBy Links for resource', + function (done) { + server.options('/sampleContainer2/example1.ttl') + .expect(hasHeader('acl', 'example1.ttl' + suffixAcl)) + .expect(hasHeader('describedBy', 'example1.ttl' + suffixMeta)) + .end(done) + }) + + it('should have set Link as resource', function (done) { + server.options('/sampleContainer2/example1.ttl') + .expect('Link', /; rel="type"/) + .end(done) + }) + + it('should have set Link as Container/BasicContainer on an implicit index page', function (done) { + server.options('/sampleContainer/') + .expect('Link', /; rel="type"/) + .expect('Link', /; rel="type"/) + .end(done) + }) + + it('should have set Link as Container/BasicContainer', function (done) { + server.options('/sampleContainer2/') + .set('Origin', 'http://example.com') + .expect('Link', /; rel="type"/) + .expect('Link', /; rel="type"/) + .end(done) + }) + + it('should have set Accept-Post for containers', function (done) { + server.options('/sampleContainer2/') + .set('Origin', 'http://example.com') + .expect('Accept-Post', '*/*') + .end(done) + }) + + it('should have set acl and describedBy Links for container', function (done) { + server.options('/sampleContainer2/') + .expect(hasHeader('acl', suffixAcl)) + .expect(hasHeader('describedBy', suffixMeta)) + .end(done) + }) + }) + + describe('Not allowed method should return 405 and allow header', function (done) { + it('TRACE should return 405', function (done) { + server.trace('/sampleContainer2/') + // .expect(hasHeader('allow', 'OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE')) + .expect(405) + .end((err, res) => { + if (err) done(err) + const allow = res.headers.allow + console.log(allow) + if (allow === 'OPTIONS, HEAD, GET, PATCH, POST, PUT, DELETE') done() + else done(new Error('no allow header')) + }) + }) + }) + + describe('GET API', function () { + it('should have the same size of the file on disk', function (done) { + server.get('/sampleContainer/solid.png') + .expect(200) + .end(function (err, res) { + if (err) { + return done(err) + } + + const size = fs.statSync(path.join(__dirname, + '../resources/sampleContainer/solid.png')).size + if (res.body.length !== size) { + return done(new Error('files are not of the same size')) + } + done() + }) + }) + + it('should have Access-Control-Allow-Origin as Origin on containers', function (done) { + server.get('/sampleContainer2/') + .set('Origin', 'http://example.com') + .expect('content-type', /text\/turtle/) + .expect('Access-Control-Allow-Origin', 'http://example.com') + .expect(200, done) + }) + it('should have Access-Control-Allow-Origin as Origin on resources', + function (done) { + server.get('/sampleContainer2/example1.ttl') + .set('Origin', 'http://example.com') + .expect('content-type', /text\/turtle/) + .expect('Access-Control-Allow-Origin', 'http://example.com') + .expect(200, done) + }) + it('should have set Link as resource', function (done) { + server.get('/sampleContainer2/example1.ttl') + .expect('content-type', /text\/turtle/) + .expect('Link', /; rel="type"/) + .expect(200, done) + }) + it('should have set Updates-Via to use WebSockets', function (done) { + server.get('/sampleContainer2/example1.ttl') + .expect('updates-via', /wss?:\/\//) + .expect(200, done) + }) + it('should have set acl and describedBy Links for resource', + function (done) { + server.get('/sampleContainer2/example1.ttl') + .expect('content-type', /text\/turtle/) + .expect(hasHeader('acl', 'example1.ttl' + suffixAcl)) + .expect(hasHeader('describedBy', 'example1.ttl' + suffixMeta)) + .end(done) + }) + it('should have set Link as Container/BasicContainer', function (done) { + server.get('/sampleContainer2/') + .expect('content-type', /text\/turtle/) + .expect('Link', /; rel="type"/) + .expect('Link', /; rel="type"/) + .expect(200, done) + }) + it('should load skin (mashlib) if resource was requested as text/html', function (done) { + server.get('/sampleContainer2/example1.ttl') + .set('Accept', 'text/html') + .expect('content-type', /text\/html/) + .expect(function (res) { + if (res.text.indexOf('TabulatorOutline') < 0) { + throw new Error('did not load the Tabulator skin by default') + } + }) + .expect(200, done) // Can't check for 303 because of internal redirects + }) + it('should NOT load data browser (mashlib) if resource is not RDF', function (done) { + server.get('/sampleContainer/solid.png') + .set('Accept', 'text/html') + .expect('content-type', /image\/png/) + .expect(200, done) + }) + + it('should NOT load data browser (mashlib) if a resource has an .html extension', function (done) { + server.get('/sampleContainer/index.html') + .set('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8') + .expect('content-type', /text\/html/) + .expect(200) + .expect((res) => { + if (res.text.includes('TabulatorOutline')) { + throw new Error('Loaded data browser though resource has an .html extension') + } + }) + .end(done) + }) + + it('should NOT load data browser (mashlib) if directory has an index file', function (done) { + server.get('/sampleContainer/') + .set('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8') + .expect('content-type', /text\/html/) + .expect(200) + .expect((res) => { + if (res.text.includes('TabulatorOutline')) { + throw new Error('Loaded data browser though resource has an .html extension') + } + }) + .end(done) + }) + + it('should show data browser if container was requested as text/html', function (done) { + server.get('/sampleContainer2/') + .set('Accept', 'text/html') + .expect('content-type', /text\/html/) + .expect(200, done) + }) + it('should redirect to the right container URI if missing /', function (done) { + server.get('/sampleContainer') + .expect(301, done) + }) + it('should return 404 for non-existent resource', function (done) { + server.get('/invalidfile.foo') + .expect(404, done) + }) + it('should return 404 for non-existent container', function (done) { + server.get('/inexistant/') + .expect('Accept-Put', 'text/turtle') + .expect(404, done) + }) + it('should return basic container link for directories', function (done) { + server.get('/') + .expect('Link', /http:\/\/www.w3.org\/ns\/ldp#BasicContainer/) + .expect('content-type', /text\/turtle/) + .expect(200, done) + }) + it('should return resource link for files', function (done) { + server.get('/hello.html') + .expect('Link', /; rel="type"/) + .expect('Content-Type', /text\/html/) + .expect(200, done) + }) + it('should have glob support', function (done) { + server.get('/sampleContainer/*') + .expect('content-type', /text\/turtle/) + .expect(200) + .expect((res) => { + const kb = rdf.graph() + rdf.parse(res.text, kb, 'https://localhost/', 'text/turtle') + + assert(kb.match( + rdf.namedNode('https://localhost/example1.ttl#this'), + rdf.namedNode('http://purl.org/dc/elements/1.1/title'), + rdf.literal('Test title') + ).length, 'Must contain a triple from example1.ttl') + + assert(kb.match( + rdf.namedNode('http://example.org/stuff/1.0/a'), + rdf.namedNode('http://example.org/stuff/1.0/b'), + rdf.literal('apple') + ).length, 'Must contain a triple from example2.ttl') + + assert(kb.match( + rdf.namedNode('http://example.org/stuff/1.0/a'), + rdf.namedNode('http://example.org/stuff/1.0/b'), + rdf.literal('The first line\nThe second line\n more') + ).length, 'Must contain a triple from example3.ttl') + }) + .end(done) + }) + it('should have set acl and describedBy Links for container', + function (done) { + server.get('/sampleContainer2/') + .expect(hasHeader('acl', suffixAcl)) + .expect(hasHeader('describedBy', suffixMeta)) + .expect('content-type', /text\/turtle/) + .end(done) + }) + it('should return requested index.html resource by default', function (done) { + server.get('/sampleContainer/index.html') + .set('accept', 'text/html') + .expect(200) + .expect('content-type', /text\/html/) + .expect(function (res) { + if (res.text.indexOf('') < 0) { + throw new Error('wrong content returned for index.html') + } + }) + .end(done) + }) + it('should fallback on index.html if it exists and content-type is given', + function (done) { + server.get('/sampleContainer/') + .set('accept', 'text/html') + .expect(200) + .expect('content-type', /text\/html/) + .end(done) + }) + it('should return turtle if requesting a conatiner that has index.html with conteent-type text/turtle', (done) => { + server.get('/sampleContainer/') + .set('accept', 'text/turtle') + .expect(200) + .expect('content-type', /text\/turtle/) + .end(done) + }) + it('should return turtle if requesting a container that conatins an index.html file with a content type where some rdf format is ranked higher than html', (done) => { + server.get('/sampleContainer/') + .set('accept', 'image/*;q=0.9, */*;q=0.1, application/rdf+xml;q=0.9, application/xhtml+xml, text/xml;q=0.5, application/xml;q=0.5, text/html;q=0.9, text/plain;q=0.5, text/n3;q=1.0, text/turtle;q=1') + .expect(200) + .expect('content-type', /text\/turtle/) + .end(done) + }) + it('should still redirect to the right container URI if missing / and HTML is requested', function (done) { + server.get('/sampleContainer') + .set('accept', 'text/html') + .expect('location', /\/sampleContainer\//) + .expect(301, done) + }) + + describe('Accept-* headers', function () { + it('should return 404 for non-existent resource', function (done) { + server.get('/invalidfile.foo') + .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') + .expect('Accept-Post', '*/*') + .expect('Accept-put', '*/*') + .expect(404, done) + }) + it('Accept-Put=text/turtle for non-existent container', function (done) { + server.get('/inexistant/') + .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') + .expect('Accept-Post', '*/*') + .expect('Accept-Put', 'text/turtle') + .expect(404, done) + }) + it('Accept-Put header do not exist for existing container', (done) => { + server.get('/sampleContainer/') + .expect(200) + .expect('Accept-Patch', 'text/n3, application/sparql-update, application/sparql-update-single-match') + .expect('Accept-Post', '*/*') + .expect((res) => { + if (res.headers['Accept-Put']) return done(new Error('Accept-Put header should not exist')) + }) + .end(done) + }) + }) + }) + + describe('HEAD API', function () { + it('should return content-type application/octet-stream by default', function (done) { + server.head('/sampleContainer/blank') + .expect('Content-Type', /application\/octet-stream/) + .end(done) + }) + it('should return content-type text/turtle for container', function (done) { + server.head('/sampleContainer2/') + .expect('Content-Type', /text\/turtle/) + .end(done) + }) + it('should have set content-type for turtle files', + function (done) { + server.head('/sampleContainer2/example1.ttl') + .expect('Content-Type', /text\/turtle/) + .end(done) + }) + it('should have set content-type for implicit turtle files', + function (done) { + server.head('/sampleContainer/example4') + .expect('Content-Type', /text\/turtle/) + .end(done) + }) + it('should have set content-type for image files', + function (done) { + server.head('/sampleContainer/solid.png') + .expect('Content-Type', /image\/png/) + .end(done) + }) + it('should have Access-Control-Allow-Origin as Origin', function (done) { + server.head('/sampleContainer2/example1.ttl') + .set('Origin', 'http://example.com') + .expect('Access-Control-Allow-Origin', 'http://example.com') + .expect(200, done) + }) + it('should return empty response body', function (done) { + server.head('/patch-5-initial.ttl') + .expect(emptyResponse) + .expect(200, done) + }) + it('should have set Updates-Via to use WebSockets', function (done) { + server.head('/sampleContainer2/example1.ttl') + .expect('updates-via', /wss?:\/\//) + .expect(200, done) + }) + it('should have set Link as Resource', function (done) { + server.head('/sampleContainer2/example1.ttl') + .expect('Link', /; rel="type"/) + .expect(200, done) + }) + it('should have set acl and describedBy Links for resource', + function (done) { + server.head('/sampleContainer2/example1.ttl') + .expect(hasHeader('acl', 'example1.ttl' + suffixAcl)) + .expect(hasHeader('describedBy', 'example1.ttl' + suffixMeta)) + .end(done) + }) + it('should have set Content-Type as text/turtle for Container', + function (done) { + server.head('/sampleContainer2/') + .expect('Content-Type', /text\/turtle/) + .expect(200, done) + }) + it('should have set Link as Container/BasicContainer', + function (done) { + server.head('/sampleContainer2/') + .expect('Link', /; rel="type"/) + .expect('Link', /; rel="type"/) + .expect(200, done) + }) + it('should have set acl and describedBy Links for container', + function (done) { + server.head('/sampleContainer2/') + .expect(hasHeader('acl', suffixAcl)) + .expect(hasHeader('describedBy', suffixMeta)) + .end(done) + }) + }) + + describe('PUT API', function () { + const putRequestBody = fs.readFileSync(path.join(__dirname, + '../resources/sampleContainer/put1.ttl'), { + encoding: 'utf8' + }) + it('should create new resource with if-none-match on non existing resource', function (done) { + server.put('/put-resource-1.ttl') + .send(putRequestBody) + .set('if-none-match', '*') + .set('content-type', 'text/plain') + .expect(201, done) + }) + it('should fail with 412 with precondition on existing resource', function (done) { + server.put('/put-resource-1.ttl') + .send(putRequestBody) + .set('if-none-match', '*') + .set('content-type', 'text/plain') + .expect(412, done) + }) + it('should fail with 400 if not content-type', function (done) { + server.put('/put-resource-1.ttl') + .send(putRequestBody) + .set('content-type', '') + .expect(400, done) + }) + it('should create new resource and delete old path if different', function (done) { + server.put('/put-resource-1.ttl') + .send(putRequestBody) + .set('content-type', 'text/turtle') + .expect(204) + .end(function (err) { + if (err) return done(err) + if (fs.existsSync(path.join(__dirname, '../resources/put-resource-1.ttl$.txt'))) { + return done(new Error('Can read old file that should have been deleted')) + } + done() + }) + }) + it('should reject create .acl resource, if contentType not text/turtle', function (done) { + server.put('/put-resource-1.acl') + .send(putRequestBody) + .set('content-type', 'text/plain') + .expect(415, done) + }) + it('should reject create .acl resource, if body is not valid turtle', function (done) { + server.put('/put-resource-1.acl') + .send('bad turtle content') + .set('content-type', 'text/turtle') + .expect(400, done) + }) + it('should reject create .meta resource, if contentType not text/turtle', function (done) { + server.put('/.meta') + .send(putRequestBody) + .set('content-type', 'text/plain') + .expect(415, done) + }) + it('should reject create .meta resource, if body is not valid turtle', function (done) { + server.put('/.meta') + .send(JSON.stringify({})) + .set('content-type', 'text/turtle') + .expect(400, done) + }) + it('should create directories if they do not exist', function (done) { + server.put('/foo/bar/baz.ttl') + .send(putRequestBody) + .set('content-type', 'text/turtle') + .expect(hasHeader('describedBy', 'baz.ttl' + suffixMeta)) + .expect(hasHeader('acl', 'baz.ttl' + suffixAcl)) + .expect(201, done) + }) + it('should not create a resource with percent-encoded $.ext', function (done) { + server.put('/foo/bar/baz%24.ttl') + .send(putRequestBody) + .set('content-type', 'text/turtle') + // .expect(hasHeader('describedBy', 'baz.ttl' + suffixMeta)) + // .expect(hasHeader('acl', 'baz.ttl' + suffixAcl)) + .expect(400, done) // 404 + }) + it('should create a resource without extension', function (done) { + server.put('/foo/bar/baz') + .send(putRequestBody) + .set('content-type', 'text/turtle') + .expect(hasHeader('describedBy', 'baz' + suffixMeta)) + .expect(hasHeader('acl', 'baz' + suffixAcl)) + .expect(201, done) + }) + it('should not create a container if a document with same name exists in tree', function (done) { + server.put('/foo/bar/baz/') + .send(putRequestBody) + // .set('content-type', 'text/turtle') + // .expect(hasHeader('describedBy', suffixMeta)) + // .expect(hasHeader('acl', suffixAcl)) + .expect(409, done) + }) + it('should not create new resource if a folder/resource with same name will exist in tree', function (done) { + server.put('/foo/bar/baz/baz1/test.ttl') + .send(putRequestBody) + .set('content-type', 'text/turtle') + .expect(hasHeader('describedBy', 'test.ttl' + suffixMeta)) + .expect(hasHeader('acl', 'test.ttl' + suffixAcl)) + .expect(409, done) + }) + it('should return 201 when trying to put to a container without content-type', + function (done) { + server.put('/foo/bar/test/') + // .set('content-type', 'text/turtle') + .set('link', '; rel="type"') + .expect(201, done) + } + ) + it('should return 204 code when trying to put to a container', + function (done) { + server.put('/foo/bar/test/') + .set('content-type', 'text/turtle') + .set('link', '; rel="type"') + .expect(204, done) + } + ) + it('should return 204 when trying to put to a container without content-type', + function (done) { + server.put('/foo/bar/test/') + // .set('content-type', 'text/turtle') + .set('link', '; rel="type"') + .expect(204, done) + } + ) + it('should return 204 code when trying to put to a container', + function (done) { + server.put('/foo/bar/test/') + .set('content-type', 'text/turtle') + .set('link', '; rel="type"') + .expect(204, done) + } + ) + it('should return a 400 error when trying to PUT a container with a name that contains a reserved suffix', + function (done) { + server.put('/foo/bar.acl/test/') + .set('content-type', 'text/turtle') + .set('link', '; rel="type"') + .expect(400, done) + } + ) + it('should return a 400 error when trying to PUT a resource with a name that contains a reserved suffix', + function (done) { + server.put('/foo/bar.acl/test.ttl') + .send(putRequestBody) + .set('content-type', 'text/turtle') + .set('link', '; rel="type"') + .expect(400, done) + } + ) + // Cleanup + after(function () { + rm('/foo/') + }) + }) + + describe('DELETE API', function () { + before(function () { + // Ensure all these are finished before running tests + return Promise.all([ + rm('/false-file-48484848'), + createTestResource('/.acl'), + createTestResource('/profile/card'), + createTestResource('/delete-test-empty-container/.meta.acl'), + createTestResource('/put-resource-1.ttl'), + createTestResource('/put-resource-with-acl.ttl'), + createTestResource('/put-resource-with-acl.ttl.acl'), + createTestResource('/put-resource-with-acl.txt'), + createTestResource('/put-resource-with-acl.txt.acl'), + createTestResource('/delete-test-non-empty/test.ttl') + ]) + }) + + it('should return 405 status when deleting root folder', function (done) { + server.delete('/') + .expect(405) + .end((err, res) => { + if (err) return done(err) + try { + assert.equal(res.get('allow').includes('DELETE'), false) + } catch (err) { + return done(err) + } + done() + }) + }) + + it('should return 405 status when deleting root acl', function (done) { + server.delete('/' + suffixAcl) + .expect(405) + .end((err, res) => { + if (err) return done(err) + try { + assert.equal(res.get('allow').includes('DELETE'), false) // ,'res methods') + } catch (err) { + return done(err) + } + done() + }) + }) + + it('should return 405 status when deleting /profile/card', function (done) { + server.delete('/profile/card') + .expect(405) + .end((err, res) => { + if (err) return done(err) + try { + assert.equal(res.get('allow').includes('DELETE'), false) // ,'res methods') + } catch (err) { + return done(err) + } + done() + }) + }) + + it('should return 404 status when deleting a file that does not exists', + function (done) { + server.delete('/false-file-48484848') + .expect(404, done) + }) + + it('should delete previously PUT file', function (done) { + server.delete('/put-resource-1.ttl') + .expect(200, done) + }) + + it('should delete previously PUT file with ACL', function (done) { + server.delete('/put-resource-with-acl.ttl') + .expect(200, done) + }) + + it('should return 404 on deleting .acl of previously deleted PUT file with ACL', function (done) { + server.delete('/put-resource-with-acl.ttl.acl') + .expect(404, done) + }) + + it('should delete previously PUT file with bad extension and with ACL', function (done) { + server.delete('/put-resource-with-acl.txt') + .expect(200, done) + }) + + it('should return 404 on deleting .acl of previously deleted PUT file with bad extension and with ACL', function (done) { + server.delete('/put-resource-with-acl.txt.acl') + .expect(404, done) + }) + + it('should fail to delete non-empty containers', function (done) { + server.delete('/delete-test-non-empty/') + .expect(409, done) + }) + + it('should delete a new and empty container - with .meta.acl', function (done) { + server.delete('/delete-test-empty-container/') + .end(() => { + server.get('/delete-test-empty-container/') + .expect(404) + .end(done) + }) + }) + + after(function () { + // Clean up after DELETE API tests + rm('/profile/') + rm('/put-resource-1.ttl') + rm('/delete-test-non-empty/') + rm('/delete-test-empty-container/test.txt.acl') + rm('/delete-test-empty-container/') + }) + }) + + describe('POST API', function () { + let postLocation + before(function () { + // Ensure all these are finished before running tests + return Promise.all([ + createTestResource('/post-tests/put-resource'), + // createTestContainer('post-tests'), + rm('post-test-target.ttl') // , + // createTestResource('/post-tests/put-resource') + ]) + }) + + const postRequest1Body = fs.readFileSync(path.join(__dirname, + '../resources/sampleContainer/put1.ttl'), { + encoding: 'utf8' + }) + const postRequest2Body = fs.readFileSync(path.join(__dirname, + '../resources/sampleContainer/post2.ttl'), { + encoding: 'utf8' + }) + // Capture the resource name generated by server by parsing Location: header + let postedResourceName + const getResourceName = function (res) { + postedResourceName = res.header.location + } + + it('should create new document resource', function (done) { + server.post('/post-tests/') + .send(postRequest1Body) + .set('content-type', 'text/turtle') + .set('slug', 'post-resource-1') + .expect('location', /\/post-resource-1/) + .expect(hasHeader('describedBy', suffixMeta)) + .expect(hasHeader('acl', suffixAcl)) + .expect(201, done) + }) + it('should create new resource even if body is empty', function (done) { + server.post('/post-tests/') + .set('slug', 'post-resource-empty') + .set('content-type', 'text/turtle') + .expect(hasHeader('describedBy', suffixMeta)) + .expect(hasHeader('acl', suffixAcl)) + .expect('location', /.*\.ttl/) + .expect(201, done) + }) + it('should create container with new slug as a resource', function (done) { + server.post('/post-tests/') + .set('content-type', 'text/turtle') + .set('slug', 'put-resource') + .set('link', '; rel="type"') + .send(postRequest2Body) + .expect(201) + .end((err, res) => { + if (err) return done(err) + try { + postLocation = res.headers.location + // console.log('location ' + postLocation) + const createdDir = fs.statSync(path.join(__dirname, '../resources', postLocation.slice(0, -1))) + assert(createdDir.isDirectory(), 'Container should have been created') + } catch (err) { + return done(err) + } + done() + }) + }) + it('should get newly created container with new slug', function (done) { + console.log('location' + postLocation) + server.get(postLocation) + .expect(200, done) + }) + it('should error with 403 if auxiliary resource file.acl', function (done) { + server.post('/post-tests/') + .set('slug', 'post-acl-no-content-type.acl') + .send(postRequest1Body) + .set('content-type', 'text/turtle') + .expect(403, done) + }) + it('should error with 403 if auxiliary resource .meta', function (done) { + server.post('/post-tests/') + .set('slug', '.meta') + .send(postRequest1Body) + .set('content-type', 'text/turtle') + .expect(403, done) + }) + it('should error with 400 if the body is empty and no content type is provided', function (done) { + server.post('/post-tests/') + .set('slug', 'post-resource-empty-fail') + .expect(400, done) + }) + it('should error with 400 if the body is provided but there is no content-type header', function (done) { + server.post('/post-tests/') + .set('slug', 'post-resource-rdf-no-content-type') + .send(postRequest1Body) + .set('content-type', '') + .expect(400, done) + }) + it('should create new resource even if no trailing / is in the target', + function (done) { + server.post('') + .send(postRequest1Body) + .set('content-type', 'text/turtle') + .set('slug', 'post-test-target') + .expect('location', /\/post-test-target\.ttl/) + .expect(hasHeader('describedBy', suffixMeta)) + .expect(hasHeader('acl', suffixAcl)) + .expect(201, done) + }) + it('should create new resource even if slug contains invalid suffix', function (done) { + server.post('/post-tests/') + .set('slug', 'put-resource.acl.ttl') + .send(postRequest1Body) + .set('content-type', 'text-turtle') + .expect(hasHeader('describedBy', suffixMeta)) + .expect(hasHeader('acl', suffixAcl)) + .expect(201, done) + }) + it('create container with recursive example', function (done) { + server.post('/post-tests/') + .set('content-type', 'text/turtle') + .set('slug', 'foo.bar.acl.meta') + .set('link', '; rel="type"') + .send(postRequest2Body) + .expect('location', /\/post-tests\/foo.bar\//) + .expect(201, done) + }) + it('should fail return 404 if no parent container found', function (done) { + server.post('/hello.html/') + .send(postRequest1Body) + .set('content-type', 'text/turtle') + .set('slug', 'post-test-target2') + .expect(404, done) + }) + it('should create a new slug if there is a resource with the same name', + function (done) { + server.post('/post-tests/') + .send(postRequest1Body) + .set('content-type', 'text/turtle') + .set('slug', 'post-resource-1') + .expect(201, done) + }) + it('should be able to delete newly created resource', function (done) { + server.delete('/post-tests/post-resource-1.ttl') + .expect(200, done) + }) + it('should create new resource without slug header', function (done) { + server.post('/post-tests/') + .send(postRequest1Body) + .set('content-type', 'text/turtle') + .expect(201) + .expect(getResourceName) + .end(done) + }) + it('should be able to delete newly created resource (2)', function (done) { + server.delete('/' + + postedResourceName.replace(/https?:\/\/((127.0.0.1)|(localhost)):[0-9]*\//, '')) + .expect(200, done) + }) + it('should create container', function (done) { + server.post('/post-tests/') + .set('content-type', 'text/turtle') + .set('slug', 'loans.ttl') + .set('link', '; rel="type"') + .send(postRequest2Body) + .expect('location', /\/post-tests\/loans.ttl\//) + .expect(201) + .end((err, res) => { + if (err) return done(err) + try { + postLocation = res.headers.location + console.log('location ' + postLocation) + const createdDir = fs.statSync(path.join(__dirname, '../resources', postLocation.slice(0, -1))) + assert(createdDir.isDirectory(), 'Container should have been created') + } catch (err) { + return done(err) + } + done() + }) + }) + it('should be able to access newly container', function (done) { + console.log(postLocation) + server.get(postLocation) + // .expect('content-type', /text\/turtle/) + .expect(200, done) + }) + it('should create container', function (done) { + server.post('/post-tests/') + .set('content-type', 'text/turtle') + .set('slug', 'loans.acl.meta') + .set('link', '; rel="type"') + .send(postRequest2Body) + .expect('location', /\/post-tests\/loans\//) + .expect(201) + .end((err, res) => { + if (err) return done(err) + try { + postLocation = res.headers.location + assert(!postLocation.endsWith('.acl/') && !postLocation.endsWith('.meta/'), 'Container name cannot end with ".acl" or ".meta"') + } catch (err) { + return done(err) + } + done() + }) + }) + it('should be able to access newly created container', function (done) { + console.log(postLocation) + server.get(postLocation) + // .expect('content-type', /text\/turtle/) + .expect(200, done) + }) + it('should create a new slug if there is a container with same name', function (done) { + server.post('/post-tests/') + .send(postRequest1Body) + .set('content-type', 'text/turtle') + .set('slug', 'loans.ttl') + .expect(201) + .expect(getResourceName) + .end(done) + }) + it('should get newly created document resource with new slug', function (done) { + console.log(postedResourceName) + server.get(postedResourceName) + .expect(200, done) + }) + it('should create a container with a name hex decoded from the slug', (done) => { + const containerName = 'Film%4011' + const expectedDirName = '/post-tests/Film@11/' + server.post('/post-tests/') + .set('slug', containerName) + .set('content-type', 'text/turtle') + .set('link', '; rel="type"') + .expect(201) + .end((err, res) => { + if (err) return done(err) + try { + assert.equal(res.headers.location, expectedDirName, + 'Uri container names should be encoded') + const createdDir = fs.statSync(path.join(__dirname, '../resources', expectedDirName)) + assert(createdDir.isDirectory(), 'Container should have been created') + } catch (err) { + return done(err) + } + done() + }) + }) + + describe('content-type-based file extensions', () => { + // ensure the container exists + before(() => + server.post('/post-tests/') + .send(postRequest1Body) + .set('content-type', 'text/turtle') + ) + + describe('a new text/turtle document posted without slug', () => { + let response + before(() => + server.post('/post-tests/') + .set('content-type', 'text/turtle; charset=utf-8') + .then(res => { response = res }) + ) + + it('is assigned an URL with the .ttl extension', () => { + expect(response.headers).to.have.property('location') + expect(response.headers.location).to.match(/^\/post-tests\/[^./]+\.ttl$/) + }) + }) + + describe('a new text/turtle document posted with a slug', () => { + let response + before(() => + server.post('/post-tests/') + .set('slug', 'slug1') + .set('content-type', 'text/turtle; charset=utf-8') + .then(res => { response = res }) + ) + + it('is assigned an URL with the .ttl extension', () => { + expect(response.headers).to.have.property('location', '/post-tests/slug1.ttl') + }) + }) + + describe('a new text/html document posted without slug', () => { + let response + before(() => + server.post('/post-tests/') + .set('content-type', 'text/html; charset=utf-8') + .then(res => { response = res }) + ) + + it('is assigned an URL with the .html extension', () => { + expect(response.headers).to.have.property('location') + expect(response.headers.location).to.match(/^\/post-tests\/[^./]+\.html$/) + }) + }) + + describe('a new text/html document posted with a slug', () => { + let response + before(() => + server.post('/post-tests/') + .set('slug', 'slug2') + .set('content-type', 'text/html; charset=utf-8') + .then(res => { response = res }) + ) + + it('is assigned an URL with the .html extension', () => { + expect(response.headers).to.have.property('location', '/post-tests/slug2.html') + }) + }) + }) + + /* No, URLs are NOT ex-encoded to make filenames -- the other way around. + it('should create a container with a url name', (done) => { + let containerName = 'https://example.com/page' + let expectedDirName = '/post-tests/https%3A%2F%2Fexample.com%2Fpage/' + server.post('/post-tests/') + .set('slug', containerName) + .set('content-type', 'text/turtle') + .set('link', '; rel="type"') + .expect(201) + .end((err, res) => { + if (err) return done(err) + try { + assert.equal(res.headers.location, expectedDirName, + 'Uri container names should be encoded') + let createdDir = fs.statSync(path.join(__dirname, 'resources', expectedDirName)) + assert(createdDir.isDirectory(), 'Container should have been created') + } catch (err) { + return done(err) + } + done() + }) + }) + + it('should be able to access new url-named container', (done) => { + let containerUrl = '/post-tests/https%3A%2F%2Fexample.com%2Fpage/' + server.get(containerUrl) + .expect('content-type', /text\/turtle/) + .expect(200, done) + }) + */ + + after(function () { + // Clean up after POST API tests + return Promise.all([ + rm('/post-tests/put-resource'), + rm('/post-tests/'), + rm('post-test-target.ttl') + ]) + }) + }) + + describe('POST (multipart)', function () { + it('should create as many files as the ones passed in multipart', + function (done) { + server.post('/sampleContainer/') + .attach('timbl', path.join(__dirname, '../resources/timbl.jpg')) + .attach('nicola', path.join(__dirname, '../resources/nicola.jpg')) + .expect(200) + .end(function (err) { + if (err) return done(err) + + const sizeNicola = fs.statSync(path.join(__dirname, + '../resources/nicola.jpg')).size + const sizeTim = fs.statSync(path.join(__dirname, '../resources/timbl.jpg')).size + const sizeNicolaLocal = fs.statSync(path.join(__dirname, + '../resources/sampleContainer/nicola.jpg')).size + const sizeTimLocal = fs.statSync(path.join(__dirname, + '../resources/sampleContainer/timbl.jpg')).size + + if (sizeNicola === sizeNicolaLocal && sizeTim === sizeTimLocal) { + return done() + } else { + return done(new Error('Either the size (remote/local) don\'t match or files are not stored')) + } + }) + }) + after(function () { + // Clean up after POST (multipart) API tests + return Promise.all([ + rm('/sampleContainer/nicola.jpg'), + rm('/sampleContainer/timbl.jpg') + ]) + }) + }) +}) diff --git a/test/integration/ldp-test.mjs b/test/integration/ldp-test.js similarity index 96% rename from test/integration/ldp-test.mjs rename to test/integration/ldp-test.js index 26e8b1962..0177ab416 100644 --- a/test/integration/ldp-test.mjs +++ b/test/integration/ldp-test.js @@ -1,528 +1,528 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import fs from 'fs' -import $rdf from 'rdflib' -import { stringToStream } from '../../lib/utils.mjs' - -// Import utility functions from the ESM utils -// const { rm, read } = await import('../utils.mjs') -import { rm, read } from '../utils.mjs' -import chai from 'chai' -import chaiAsPromised from 'chai-as-promised' -import LDP from '../../lib/ldp.mjs' -import { randomBytes } from 'node:crypto' -import ResourceMapper from '../../lib/resource-mapper.mjs' -import intoStream from 'into-stream' -import nsImport from 'solid-namespace' -const ns = nsImport($rdf) - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -chai.use(chaiAsPromised) -const assert = chai.assert - -describe('LDP', function () { - const root = path.join(__dirname, '../../test/resources/ldp-test/') - - const resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - rootPath: root, - includeHost: false - }) - - const ldp = new LDP({ - resourceMapper, - serverUri: 'https://localhost/', - multiuser: true, - webid: false - }) - - const rootQuota = path.join(__dirname, '../../test/resources/ldp-test-quota/') - const resourceMapperQuota = new ResourceMapper({ - rootUrl: 'https://localhost:8444/', - rootPath: rootQuota, - includeHost: false - }) - - const ldpQuota = new LDP({ - resourceMapper: resourceMapperQuota, - serverUri: 'https://localhost/', - multiuser: true, - webid: false - }) - - this.beforeAll(() => { - const metaData = `# Root Meta resource for the user account - # Used to discover the account's WebID URI, given the account URI - - - .` - - const example1TurtleData = `@prefix rdf: . - @prefix dc: . - @prefix ex: . - - <#this> dc:title "Test title" . - - - dc:title "RDF/XML Syntax Specification (Revised)" ; - ex:editor [ - ex:fullname "Dave Beckett"; - ex:homePage - ] .` - fs.mkdirSync(root, { recursive: true }) - fs.mkdirSync(path.join(root, '/resources/'), { recursive: true }) - fs.mkdirSync(path.join(root, '/resources/sampleContainer/'), { recursive: true }) - fs.writeFileSync(path.join(root, '.meta'), metaData) - fs.writeFileSync(path.join(root, 'resources/sampleContainer/example1.ttl'), example1TurtleData) - - const settingsTtlData = `@prefix dct: . - @prefix pim: . - @prefix solid: . - @prefix unit: . - - <> - a pim:ConfigurationFile; - - dct:description "Administrative settings for the server that are only readable to the user." . - - - solid:storageQuota "1230" .` - - fs.mkdirSync(rootQuota, { recursive: true }) - fs.mkdirSync(path.join(rootQuota, 'settings/'), { recursive: true }) - fs.writeFileSync(path.join(rootQuota, 'settings/serverSide.ttl'), settingsTtlData) - }) - - this.afterAll(() => { - fs.rmSync(root, { recursive: true, force: true }) - fs.rmSync(rootQuota, { recursive: true, force: true }) - }) - - describe('cannot delete podRoot', function () { - it('should error 405 when deleting podRoot', () => { - return ldp.delete('/').catch(err => { - assert.equal(err.status, 405) - }) - }) - it('should error 405 when deleting podRoot/.acl', async () => { - await ldp.put('/.acl', intoStream(''), 'text/turtle') - return ldp.delete('/.acl').catch(err => { - assert.equal(err.status, 405) - }) - }) - }) - - describe('readResource', function () { - it('return 404 if file does not exist', () => { - // had to create the resources folder beforehand, otherwise throws 500 error - return ldp.readResource('/resources/unexistent.ttl').catch(err => { - assert.equal(err.status, 404) - }) - }) - - it('return file if file exists', () => { - // file can be empty as well - fs.writeFileSync(path.join(root, '/resources/fileExists.txt'), 'hello world') - return ldp.readResource('/resources/fileExists.txt').then(file => { - assert.equal(file, 'hello world') - }) - }) - }) - - describe('readContainerMeta', () => { - it('should return 404 if .meta is not found', () => { - return ldp.readContainerMeta('/resources/sampleContainer/').catch(err => { - assert.equal(err.status, 404) - }) - }) - - it('should return content if metaFile exists', () => { - // file can be empty as well - // write('This function just reads this, does not parse it', 'sampleContainer/.meta') - fs.writeFileSync(path.join(root, 'resources/sampleContainer/.meta'), 'This function just reads this, does not parse it') - return ldp.readContainerMeta('/resources/sampleContainer/').then(metaFile => { - // rm('sampleContainer/.meta') - assert.equal(metaFile, 'This function just reads this, does not parse it') - }) - }) - - it('should work also if trailing `/` is not passed', () => { - // file can be empty as well - // write('This function just reads this, does not parse it', 'sampleContainer/.meta') - fs.writeFileSync(path.join(root, 'resources/sampleContainer/.meta'), 'This function just reads this, does not parse it') - return ldp.readContainerMeta('/resources/sampleContainer').then(metaFile => { - // rm('sampleContainer/.meta') - assert.equal(metaFile, 'This function just reads this, does not parse it') - }) - }) - }) - - describe('isOwner', () => { - it('should return acl:owner true', () => { - const owner = 'https://tim.localhost:7777/profile/card#me' - return ldp.isOwner(owner, '/resources/') - .then(isOwner => { - assert.equal(isOwner, true) - }) - }) - it('should return acl:owner false', () => { - const owner = 'https://tim.localhost:7777/profile/card' - return ldp.isOwner(owner, '/resources/') - .then(isOwner => { - assert.equal(isOwner, false) - }) - }) - }) - - describe('getGraph', () => { - it('should read and parse an existing file', () => { - const uri = 'https://localhost:8443/resources/sampleContainer/example1.ttl' - return ldp.getGraph(uri) - .then(graph => { - assert.ok(graph) - const fullname = $rdf.namedNode('http://example.org/stuff/1.0/fullname') - const match = graph.match(null, fullname) - assert.equal(match[0].object.value, 'Dave Beckett') - }) - }) - - it('should throw a 404 error on a non-existing file', (done) => { - const uri = 'https://localhost:8443/resources/nonexistent.ttl' - ldp.getGraph(uri) - .catch(error => { - assert.ok(error) - assert.equal(error.status, 404) - done() - }) - }) - }) - - describe('putGraph', () => { - it('should serialize and write a graph to a file', () => { - const originalResource = '/resources/sampleContainer/example1.ttl' - const newResource = '/resources/sampleContainer/example1-copy.ttl' - - const uri = 'https://localhost:8443' + originalResource - return ldp.getGraph(uri) - .then(graph => { - const newUri = 'https://localhost:8443' + newResource - return ldp.putGraph(graph, newUri) - }) - .then(() => { - // Graph serialized and written - const written = read('ldp-test/resources/sampleContainer/example1-copy.ttl') - assert.ok(written) - }) - // cleanup - .then(() => { rm('ldp-test/resources/sampleContainer/example1-copy.ttl') }) - .catch(() => { rm('ldp-test/resources/sampleContainer/example1-copy.ttl') }) - }) - }) - - describe('put', function () { - it('should write a file in an existing dir', () => { - const stream = stringToStream('hello world') - return ldp.put('/resources/testPut.txt', stream, 'text/plain').then(() => { - const found = fs.readFileSync(path.join(root, '/resources/testPut.txt')) - assert.equal(found, 'hello world') - }) - }) - - /// BELOW HERE IS NOT WORKING - it.skip('should fail if a trailing `/` is passed', () => { - const stream = stringToStream('hello world') - return ldp.put('/resources/', stream, 'text/plain').catch(err => { - assert.equal(err, 409) - }) - }) - - it.skip('with a larger file to exceed allowed quota', function () { - const randstream = stringToStream(randomBytes(300000).toString()) - return ldp.put('/resources/testQuota.txt', randstream, 'text/plain').catch((err) => { - assert.notOk(err) - assert.equal(err.status, 413) - }) - }) - - it.skip('should fail if a over quota', function () { - const hellostream = stringToStream('hello world') - return ldpQuota.put('/resources/testOverQuota.txt', hellostream, 'text/plain').catch((err) => { - assert.equal(err.status, 413) - }) - }) - - it.skip('should fail if a trailing `/` is passed without content type', () => { - const stream = stringToStream('hello world') - return ldp.put('/resources/', stream, null).catch(err => { - assert.equal(err.status, 419) - }) - }) - /// ABOVE HERE IS BUGGED - - it('should fail if no content type is passed', () => { - const stream = stringToStream('hello world') - return ldp.put('/resources/testPut.txt', stream, null).catch(err => { - assert.equal(err.status, 400) - }) - }) - }) - - describe('delete', function () { - // FIXME: https://github.com/solid/node-solid-server/issues/1502 - // has to be changed from testPut.txt because depending on - // other files in tests is bad practice. - it('should error when deleting a non-existing file', () => { - return assert.isRejected(ldp.delete('/resources/testPut2.txt')) - }) - - it('should delete a file with ACL in an existing dir', async () => { - // First create a dummy file - const stream = stringToStream('hello world') - await ldp.put('/resources/testPut.txt', stream, 'text/plain') - await ldp.put('/resources/testPut.txt.acl', stream, 'text/turtle') - // Make sure it exists - fs.stat(ldp.resourceMapper._rootPath + '/resources/testPut.txt', function (err) { - if (err) { - throw err - } - }) - fs.stat(ldp.resourceMapper._rootPath + '/resources/testPut.txt.acl', function (err) { - if (err) { - throw err - } - }) - - // Now delete the dummy file - await ldp.delete('/resources/testPut.txt') - // Make sure it does not exist anymore - fs.stat(ldp.resourceMapper._rootPath + '/resources/testPut.txt', function (err, s) { - if (!err) { - throw new Error('file still exists') - } - }) - fs.stat(ldp.resourceMapper._rootPath + '/resources/testPut.txt.acl', function (err, s) { - if (!err) { - throw new Error('file still exists') - } - }) - }) - - it('should fail to delete a non-empty folder', async () => { - // First create a dummy file - const stream = stringToStream('hello world') - await ldp.put('/resources/dummy/testPutBlocking.txt', stream, 'text/plain') - // Make sure it exists - fs.stat(ldp.resourceMapper._rootPath + '/resources/dummy/testPutBlocking.txt', function (err) { - if (err) { - throw err - } - }) - - // Now try to delete its folder - return assert.isRejected(ldp.delete('/resources/dummy/')) - }) - - it('should fail to delete nested non-empty folders', async () => { - // First create a dummy file - const stream = stringToStream('hello world') - await ldp.put('/resources/dummy/dummy2/testPutBlocking.txt', stream, 'text/plain') - // Make sure it exists - fs.stat(ldp.resourceMapper._rootPath + '/resources/dummy/dummy2/testPutBlocking.txt', function (err) { - if (err) { - throw err - } - }) - - // Now try to delete its parent folder - return assert.isRejected(ldp.delete('/resources/dummy/')) - }) - - after(async function () { - // Clean up after delete tests - try { - await ldp.delete('/resources/dummy/testPutBlocking.txt') - await ldp.delete('/resources/dummy/dummy2/testPutBlocking.txt') - await ldp.delete('/resources/dummy/dummy2/') - await ldp.delete('/resources/dummy/') - } catch (err) { - - } - }) - }) - - describe('listContainer', function () { - beforeEach(() => { - // Clean up any test files before each test - try { - fs.unlinkSync(path.join(root, 'resources/sampleContainer/containerFile.ttl')) - } catch (e) { /* ignore */ } - try { - fs.unlinkSync(path.join(root, 'resources/sampleContainer/basicContainerFile.ttl')) - } catch (e) { /* ignore */ } - }) - - /* - it('should inherit type if file is .ttl', function (done) { - write('@prefix dcterms: .' + - '@prefix o: .' + - '<> a ;' + - ' dcterms:title "This is a magic type" ;' + - ' o:limit 500000.00 .', 'sampleContainer/magicType.ttl') - - ldp.listContainer(path.join(__dirname, '../../test/resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', 'https://server.tld', '', 'application/octet-stream', function (err, data) { - if (err) done(err) - var graph = $rdf.graph() - $rdf.parse( - data, - graph, - 'https://server.tld/sampleContainer', - 'text/turtle') - - var statements = graph - .each( - $rdf.sym('https://server.tld/magicType.ttl'), - ns.rdf('type'), - undefined) - .map(function (d) { - return d.uri - }) - // statements should be: - // [ 'http://www.w3.org/ns/iana/media-types/text/turtle#Resource', - // 'http://www.w3.org/ns/ldp#MagicType', - // 'http://www.w3.org/ns/ldp#Resource' ] - assert.equal(statements.length, 3) - assert.isAbove(statements.indexOf('http://www.w3.org/ns/ldp#MagicType'), -1) - assert.isAbove(statements.indexOf('http://www.w3.org/ns/ldp#Resource'), -1) - - rm('sampleContainer/magicType.ttl') - done() - }) - }) -*/ - it('should not inherit type of BasicContainer/Container if type is File', () => { - const containerFileData = `@prefix dcterms: . -@prefix o: . -<> a ; - dcterms:title "This is a container" ; - o:limit 500000.00 .` - fs.writeFileSync(path.join(root, '/resources/sampleContainer/containerFile.ttl'), containerFileData) - const basicContainerFileData = `@prefix dcterms: . -@prefix o: . -<> a ; - dcterms:title "This is a container" ; - o:limit 500000.00 .` - fs.writeFileSync(path.join(root, '/resources/sampleContainer/basicContainerFile.ttl'), basicContainerFileData) - - return ldp.listContainer(path.join(root, '/resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', '', 'server.tld') - .then(data => { - const graph = $rdf.graph() - $rdf.parse( - data, - graph, - 'https://localhost:8443/resources/sampleContainer', - 'text/turtle') - - // Find the basicContainerFile.ttl resource and get its type statements - // Use direct graph.statements filtering for maximum compatibility - const targetFile = 'basicContainerFile.ttl' - let basicContainerStatements = [] - - // Find the subject URL that ends with our target file - const matchingSubjects = graph.statements - .map(stmt => stmt.subject.value) - .filter(subject => subject.endsWith(targetFile)) - - if (matchingSubjects.length > 0) { - const subjectUrl = matchingSubjects[0] - - // Get all type statements for this subject - basicContainerStatements = graph.statements - .filter(stmt => - stmt.subject.value === subjectUrl && - stmt.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' - ) - .map(stmt => stmt.object.value) - } - - const expectedStatements = [ - 'http://www.w3.org/ns/iana/media-types/text/turtle#Resource', - 'http://www.w3.org/ns/ldp#Resource' - ] - - assert.deepEqual(basicContainerStatements.sort(), expectedStatements) - - // Also check containerFile.ttl using the same robust approach - const containerFile = 'containerFile.ttl' - const containerMatchingSubjects = graph.statements - .map(stmt => stmt.subject.value) - .filter(subject => subject.endsWith(containerFile)) - - let containerStatements = [] - if (containerMatchingSubjects.length > 0) { - const containerSubjectUrl = containerMatchingSubjects[0] - containerStatements = graph.statements - .filter(stmt => - stmt.subject.value === containerSubjectUrl && - stmt.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' - ) - .map(stmt => stmt.object.value) - } - - assert.deepEqual(containerStatements.sort(), expectedStatements) - - // Clean up synchronously - try { - fs.unlinkSync(path.join(root, 'resources/sampleContainer/containerFile.ttl')) - fs.unlinkSync(path.join(root, 'resources/sampleContainer/basicContainerFile.ttl')) - } catch (e) { /* ignore cleanup errors */ } - }) - }) - - it('should ldp:contains the same files in dir', (done) => { - ldp.listContainer(path.join(__dirname, '../../test/resources/ldp-test/resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', '', 'server.tld') - .then(data => { - fs.readdir(path.join(__dirname, '../../test/resources/ldp-test/resources/sampleContainer/'), function (err, expectedFiles) { - try { - if (err) { - return done(err) - } - - // Filter out empty strings and strip dollar extension - // Also filter out .meta files since LDP doesn't list auxiliary files - expectedFiles = expectedFiles - .filter(file => file !== '') - .filter(file => !file.startsWith('.meta')) - .map(ldp.resourceMapper._removeDollarExtension) - - const graph = $rdf.graph() - $rdf.parse(data, graph, 'https://localhost:8443/resources/sampleContainer/', 'text/turtle') - const statements = graph.match(null, ns.ldp('contains'), null) - const files = statements - .map(s => { - const url = s.object.value - const filename = url.replace(/.*\//, '') - // For directories, the URL ends with '/' so after regex we get empty string - // In this case, get the directory name from before the final '/' - if (filename === '' && url.endsWith('/')) { - return url.replace(/\/$/, '').replace(/.*\//, '') - } - return filename - }) - .map(decodeURIComponent) - .filter(file => file !== '') - - files.sort() - expectedFiles.sort() - assert.deepEqual(files, expectedFiles) - done() - } catch (error) { - done(error) - } - }) - }) - .catch(done) - }) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import fs from 'fs' +import $rdf from 'rdflib' +import { stringToStream } from '../../lib/utils.js' + +// Import utility functions from the ESM utils +// const { rm, read } = await import('../utils.js') +import { rm, read } from '../utils.js' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import LDP from '../../lib/ldp.js' +import { randomBytes } from 'node:crypto' +import ResourceMapper from '../../lib/resource-mapper.js' +import intoStream from 'into-stream' +import nsImport from 'solid-namespace' +const ns = nsImport($rdf) + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +chai.use(chaiAsPromised) +const assert = chai.assert + +describe('LDP', function () { + const root = path.join(__dirname, '../../test/resources/ldp-test/') + + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + rootPath: root, + includeHost: false + }) + + const ldp = new LDP({ + resourceMapper, + serverUri: 'https://localhost/', + multiuser: true, + webid: false + }) + + const rootQuota = path.join(__dirname, '../../test/resources/ldp-test-quota/') + const resourceMapperQuota = new ResourceMapper({ + rootUrl: 'https://localhost:8444/', + rootPath: rootQuota, + includeHost: false + }) + + const ldpQuota = new LDP({ + resourceMapper: resourceMapperQuota, + serverUri: 'https://localhost/', + multiuser: true, + webid: false + }) + + this.beforeAll(() => { + const metaData = `# Root Meta resource for the user account + # Used to discover the account's WebID URI, given the account URI + + + .` + + const example1TurtleData = `@prefix rdf: . + @prefix dc: . + @prefix ex: . + + <#this> dc:title "Test title" . + + + dc:title "RDF/XML Syntax Specification (Revised)" ; + ex:editor [ + ex:fullname "Dave Beckett"; + ex:homePage + ] .` + fs.mkdirSync(root, { recursive: true }) + fs.mkdirSync(path.join(root, '/resources/'), { recursive: true }) + fs.mkdirSync(path.join(root, '/resources/sampleContainer/'), { recursive: true }) + fs.writeFileSync(path.join(root, '.meta'), metaData) + fs.writeFileSync(path.join(root, 'resources/sampleContainer/example1.ttl'), example1TurtleData) + + const settingsTtlData = `@prefix dct: . + @prefix pim: . + @prefix solid: . + @prefix unit: . + + <> + a pim:ConfigurationFile; + + dct:description "Administrative settings for the server that are only readable to the user." . + + + solid:storageQuota "1230" .` + + fs.mkdirSync(rootQuota, { recursive: true }) + fs.mkdirSync(path.join(rootQuota, 'settings/'), { recursive: true }) + fs.writeFileSync(path.join(rootQuota, 'settings/serverSide.ttl'), settingsTtlData) + }) + + this.afterAll(() => { + fs.rmSync(root, { recursive: true, force: true }) + fs.rmSync(rootQuota, { recursive: true, force: true }) + }) + + describe('cannot delete podRoot', function () { + it('should error 405 when deleting podRoot', () => { + return ldp.delete('/').catch(err => { + assert.equal(err.status, 405) + }) + }) + it('should error 405 when deleting podRoot/.acl', async () => { + await ldp.put('/.acl', intoStream(''), 'text/turtle') + return ldp.delete('/.acl').catch(err => { + assert.equal(err.status, 405) + }) + }) + }) + + describe('readResource', function () { + it('return 404 if file does not exist', () => { + // had to create the resources folder beforehand, otherwise throws 500 error + return ldp.readResource('/resources/unexistent.ttl').catch(err => { + assert.equal(err.status, 404) + }) + }) + + it('return file if file exists', () => { + // file can be empty as well + fs.writeFileSync(path.join(root, '/resources/fileExists.txt'), 'hello world') + return ldp.readResource('/resources/fileExists.txt').then(file => { + assert.equal(file, 'hello world') + }) + }) + }) + + describe('readContainerMeta', () => { + it('should return 404 if .meta is not found', () => { + return ldp.readContainerMeta('/resources/sampleContainer/').catch(err => { + assert.equal(err.status, 404) + }) + }) + + it('should return content if metaFile exists', () => { + // file can be empty as well + // write('This function just reads this, does not parse it', 'sampleContainer/.meta') + fs.writeFileSync(path.join(root, 'resources/sampleContainer/.meta'), 'This function just reads this, does not parse it') + return ldp.readContainerMeta('/resources/sampleContainer/').then(metaFile => { + // rm('sampleContainer/.meta') + assert.equal(metaFile, 'This function just reads this, does not parse it') + }) + }) + + it('should work also if trailing `/` is not passed', () => { + // file can be empty as well + // write('This function just reads this, does not parse it', 'sampleContainer/.meta') + fs.writeFileSync(path.join(root, 'resources/sampleContainer/.meta'), 'This function just reads this, does not parse it') + return ldp.readContainerMeta('/resources/sampleContainer').then(metaFile => { + // rm('sampleContainer/.meta') + assert.equal(metaFile, 'This function just reads this, does not parse it') + }) + }) + }) + + describe('isOwner', () => { + it('should return acl:owner true', () => { + const owner = 'https://tim.localhost:7777/profile/card#me' + return ldp.isOwner(owner, '/resources/') + .then(isOwner => { + assert.equal(isOwner, true) + }) + }) + it('should return acl:owner false', () => { + const owner = 'https://tim.localhost:7777/profile/card' + return ldp.isOwner(owner, '/resources/') + .then(isOwner => { + assert.equal(isOwner, false) + }) + }) + }) + + describe('getGraph', () => { + it('should read and parse an existing file', () => { + const uri = 'https://localhost:8443/resources/sampleContainer/example1.ttl' + return ldp.getGraph(uri) + .then(graph => { + assert.ok(graph) + const fullname = $rdf.namedNode('http://example.org/stuff/1.0/fullname') + const match = graph.match(null, fullname) + assert.equal(match[0].object.value, 'Dave Beckett') + }) + }) + + it('should throw a 404 error on a non-existing file', (done) => { + const uri = 'https://localhost:8443/resources/nonexistent.ttl' + ldp.getGraph(uri) + .catch(error => { + assert.ok(error) + assert.equal(error.status, 404) + done() + }) + }) + }) + + describe('putGraph', () => { + it('should serialize and write a graph to a file', () => { + const originalResource = '/resources/sampleContainer/example1.ttl' + const newResource = '/resources/sampleContainer/example1-copy.ttl' + + const uri = 'https://localhost:8443' + originalResource + return ldp.getGraph(uri) + .then(graph => { + const newUri = 'https://localhost:8443' + newResource + return ldp.putGraph(graph, newUri) + }) + .then(() => { + // Graph serialized and written + const written = read('ldp-test/resources/sampleContainer/example1-copy.ttl') + assert.ok(written) + }) + // cleanup + .then(() => { rm('ldp-test/resources/sampleContainer/example1-copy.ttl') }) + .catch(() => { rm('ldp-test/resources/sampleContainer/example1-copy.ttl') }) + }) + }) + + describe('put', function () { + it('should write a file in an existing dir', () => { + const stream = stringToStream('hello world') + return ldp.put('/resources/testPut.txt', stream, 'text/plain').then(() => { + const found = fs.readFileSync(path.join(root, '/resources/testPut.txt')) + assert.equal(found, 'hello world') + }) + }) + + /// BELOW HERE IS NOT WORKING + it.skip('should fail if a trailing `/` is passed', () => { + const stream = stringToStream('hello world') + return ldp.put('/resources/', stream, 'text/plain').catch(err => { + assert.equal(err, 409) + }) + }) + + it.skip('with a larger file to exceed allowed quota', function () { + const randstream = stringToStream(randomBytes(300000).toString()) + return ldp.put('/resources/testQuota.txt', randstream, 'text/plain').catch((err) => { + assert.notOk(err) + assert.equal(err.status, 413) + }) + }) + + it.skip('should fail if a over quota', function () { + const hellostream = stringToStream('hello world') + return ldpQuota.put('/resources/testOverQuota.txt', hellostream, 'text/plain').catch((err) => { + assert.equal(err.status, 413) + }) + }) + + it.skip('should fail if a trailing `/` is passed without content type', () => { + const stream = stringToStream('hello world') + return ldp.put('/resources/', stream, null).catch(err => { + assert.equal(err.status, 419) + }) + }) + /// ABOVE HERE IS BUGGED + + it('should fail if no content type is passed', () => { + const stream = stringToStream('hello world') + return ldp.put('/resources/testPut.txt', stream, null).catch(err => { + assert.equal(err.status, 400) + }) + }) + }) + + describe('delete', function () { + // FIXME: https://github.com/solid/node-solid-server/issues/1502 + // has to be changed from testPut.txt because depending on + // other files in tests is bad practice. + it('should error when deleting a non-existing file', () => { + return assert.isRejected(ldp.delete('/resources/testPut2.txt')) + }) + + it('should delete a file with ACL in an existing dir', async () => { + // First create a dummy file + const stream = stringToStream('hello world') + await ldp.put('/resources/testPut.txt', stream, 'text/plain') + await ldp.put('/resources/testPut.txt.acl', stream, 'text/turtle') + // Make sure it exists + fs.stat(ldp.resourceMapper._rootPath + '/resources/testPut.txt', function (err) { + if (err) { + throw err + } + }) + fs.stat(ldp.resourceMapper._rootPath + '/resources/testPut.txt.acl', function (err) { + if (err) { + throw err + } + }) + + // Now delete the dummy file + await ldp.delete('/resources/testPut.txt') + // Make sure it does not exist anymore + fs.stat(ldp.resourceMapper._rootPath + '/resources/testPut.txt', function (err, s) { + if (!err) { + throw new Error('file still exists') + } + }) + fs.stat(ldp.resourceMapper._rootPath + '/resources/testPut.txt.acl', function (err, s) { + if (!err) { + throw new Error('file still exists') + } + }) + }) + + it('should fail to delete a non-empty folder', async () => { + // First create a dummy file + const stream = stringToStream('hello world') + await ldp.put('/resources/dummy/testPutBlocking.txt', stream, 'text/plain') + // Make sure it exists + fs.stat(ldp.resourceMapper._rootPath + '/resources/dummy/testPutBlocking.txt', function (err) { + if (err) { + throw err + } + }) + + // Now try to delete its folder + return assert.isRejected(ldp.delete('/resources/dummy/')) + }) + + it('should fail to delete nested non-empty folders', async () => { + // First create a dummy file + const stream = stringToStream('hello world') + await ldp.put('/resources/dummy/dummy2/testPutBlocking.txt', stream, 'text/plain') + // Make sure it exists + fs.stat(ldp.resourceMapper._rootPath + '/resources/dummy/dummy2/testPutBlocking.txt', function (err) { + if (err) { + throw err + } + }) + + // Now try to delete its parent folder + return assert.isRejected(ldp.delete('/resources/dummy/')) + }) + + after(async function () { + // Clean up after delete tests + try { + await ldp.delete('/resources/dummy/testPutBlocking.txt') + await ldp.delete('/resources/dummy/dummy2/testPutBlocking.txt') + await ldp.delete('/resources/dummy/dummy2/') + await ldp.delete('/resources/dummy/') + } catch (err) { + + } + }) + }) + + describe('listContainer', function () { + beforeEach(() => { + // Clean up any test files before each test + try { + fs.unlinkSync(path.join(root, 'resources/sampleContainer/containerFile.ttl')) + } catch (e) { /* ignore */ } + try { + fs.unlinkSync(path.join(root, 'resources/sampleContainer/basicContainerFile.ttl')) + } catch (e) { /* ignore */ } + }) + + /* + it('should inherit type if file is .ttl', function (done) { + write('@prefix dcterms: .' + + '@prefix o: .' + + '<> a ;' + + ' dcterms:title "This is a magic type" ;' + + ' o:limit 500000.00 .', 'sampleContainer/magicType.ttl') + + ldp.listContainer(path.join(__dirname, '../../test/resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', 'https://server.tld', '', 'application/octet-stream', function (err, data) { + if (err) done(err) + var graph = $rdf.graph() + $rdf.parse( + data, + graph, + 'https://server.tld/sampleContainer', + 'text/turtle') + + var statements = graph + .each( + $rdf.sym('https://server.tld/magicType.ttl'), + ns.rdf('type'), + undefined) + .map(function (d) { + return d.uri + }) + // statements should be: + // [ 'http://www.w3.org/ns/iana/media-types/text/turtle#Resource', + // 'http://www.w3.org/ns/ldp#MagicType', + // 'http://www.w3.org/ns/ldp#Resource' ] + assert.equal(statements.length, 3) + assert.isAbove(statements.indexOf('http://www.w3.org/ns/ldp#MagicType'), -1) + assert.isAbove(statements.indexOf('http://www.w3.org/ns/ldp#Resource'), -1) + + rm('sampleContainer/magicType.ttl') + done() + }) + }) +*/ + it('should not inherit type of BasicContainer/Container if type is File', () => { + const containerFileData = `@prefix dcterms: . +@prefix o: . +<> a ; + dcterms:title "This is a container" ; + o:limit 500000.00 .` + fs.writeFileSync(path.join(root, '/resources/sampleContainer/containerFile.ttl'), containerFileData) + const basicContainerFileData = `@prefix dcterms: . +@prefix o: . +<> a ; + dcterms:title "This is a container" ; + o:limit 500000.00 .` + fs.writeFileSync(path.join(root, '/resources/sampleContainer/basicContainerFile.ttl'), basicContainerFileData) + + return ldp.listContainer(path.join(root, '/resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', '', 'server.tld') + .then(data => { + const graph = $rdf.graph() + $rdf.parse( + data, + graph, + 'https://localhost:8443/resources/sampleContainer', + 'text/turtle') + + // Find the basicContainerFile.ttl resource and get its type statements + // Use direct graph.statements filtering for maximum compatibility + const targetFile = 'basicContainerFile.ttl' + let basicContainerStatements = [] + + // Find the subject URL that ends with our target file + const matchingSubjects = graph.statements + .map(stmt => stmt.subject.value) + .filter(subject => subject.endsWith(targetFile)) + + if (matchingSubjects.length > 0) { + const subjectUrl = matchingSubjects[0] + + // Get all type statements for this subject + basicContainerStatements = graph.statements + .filter(stmt => + stmt.subject.value === subjectUrl && + stmt.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' + ) + .map(stmt => stmt.object.value) + } + + const expectedStatements = [ + 'http://www.w3.org/ns/iana/media-types/text/turtle#Resource', + 'http://www.w3.org/ns/ldp#Resource' + ] + + assert.deepEqual(basicContainerStatements.sort(), expectedStatements) + + // Also check containerFile.ttl using the same robust approach + const containerFile = 'containerFile.ttl' + const containerMatchingSubjects = graph.statements + .map(stmt => stmt.subject.value) + .filter(subject => subject.endsWith(containerFile)) + + let containerStatements = [] + if (containerMatchingSubjects.length > 0) { + const containerSubjectUrl = containerMatchingSubjects[0] + containerStatements = graph.statements + .filter(stmt => + stmt.subject.value === containerSubjectUrl && + stmt.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type' + ) + .map(stmt => stmt.object.value) + } + + assert.deepEqual(containerStatements.sort(), expectedStatements) + + // Clean up synchronously + try { + fs.unlinkSync(path.join(root, 'resources/sampleContainer/containerFile.ttl')) + fs.unlinkSync(path.join(root, 'resources/sampleContainer/basicContainerFile.ttl')) + } catch (e) { /* ignore cleanup errors */ } + }) + }) + + it('should ldp:contains the same files in dir', (done) => { + ldp.listContainer(path.join(__dirname, '../../test/resources/ldp-test/resources/sampleContainer/'), 'https://server.tld/resources/sampleContainer/', '', 'server.tld') + .then(data => { + fs.readdir(path.join(__dirname, '../../test/resources/ldp-test/resources/sampleContainer/'), function (err, expectedFiles) { + try { + if (err) { + return done(err) + } + + // Filter out empty strings and strip dollar extension + // Also filter out .meta files since LDP doesn't list auxiliary files + expectedFiles = expectedFiles + .filter(file => file !== '') + .filter(file => !file.startsWith('.meta')) + .map(ldp.resourceMapper._removeDollarExtension) + + const graph = $rdf.graph() + $rdf.parse(data, graph, 'https://localhost:8443/resources/sampleContainer/', 'text/turtle') + const statements = graph.match(null, ns.ldp('contains'), null) + const files = statements + .map(s => { + const url = s.object.value + const filename = url.replace(/.*\//, '') + // For directories, the URL ends with '/' so after regex we get empty string + // In this case, get the directory name from before the final '/' + if (filename === '' && url.endsWith('/')) { + return url.replace(/\/$/, '').replace(/.*\//, '') + } + return filename + }) + .map(decodeURIComponent) + .filter(file => file !== '') + + files.sort() + expectedFiles.sort() + assert.deepEqual(files, expectedFiles) + done() + } catch (error) { + done(error) + } + }) + }) + .catch(done) + }) + }) +}) diff --git a/test/integration/oidc-manager-test.mjs b/test/integration/oidc-manager-test.js similarity index 95% rename from test/integration/oidc-manager-test.mjs rename to test/integration/oidc-manager-test.js index 6005016f4..935d479cb 100644 --- a/test/integration/oidc-manager-test.mjs +++ b/test/integration/oidc-manager-test.js @@ -1,135 +1,135 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import { URL } from 'url' -import chai from 'chai' -import fs from 'fs-extra' -import { fromServerConfig } from '../../lib/models/oidc-manager.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' - -const { expect } = chai - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const dbPath = path.join(__dirname, '../resources/.db') - -describe('OidcManager', () => { - beforeEach(() => { - fs.removeSync(dbPath) - }) - - describe('fromServerConfig()', () => { - it('should result in an initialized oidc object', () => { - const providerUri = 'https://localhost:8443' - const host = SolidHost.from({ providerUri }) - - const saltRounds = 5 - const argv = { - host, - dbPath, - saltRounds - } - - const oidc = fromServerConfig(argv) - - expect(oidc.rs.defaults.query).to.be.true - expect(oidc.clients.store.backend.path.endsWith('db/oidc/rp/clients')) - expect(oidc.provider.issuer).to.equal(providerUri) - expect(oidc.users.backend.path.endsWith('db/oidc/users')) - expect(oidc.users.saltRounds).to.equal(saltRounds) - }) - - it('should set the provider issuer which is used for iss claim in tokens', () => { - const providerUri = 'https://pivot-test.solidproject.org:8443' - const host = SolidHost.from({ serverUri: providerUri }) - - const saltRounds = 5 - const argv = { - host, - dbPath, - saltRounds - } - - const oidc = fromServerConfig(argv) - - // Verify the issuer is set correctly for RFC 9207 compliance - // The iss claim in tokens should match this issuer value - expect(oidc.provider.issuer).to.exist - expect(oidc.provider.issuer).to.not.be.null - expect(oidc.provider.issuer).to.equal(providerUri) - console.log('Provider issuer (used for iss claim):', oidc.provider.issuer) - }) - }) - - describe('RFC 9207 - Authorization redirect with iss parameter', () => { - it('should include iss parameter when redirecting after authorization', async () => { - const providerUri = 'https://localhost:8443' - const host = SolidHost.from({ providerUri }) - - const argv = { - host, - dbPath, - saltRounds: 5 - } - - const oidc = fromServerConfig(argv) - - // Dynamically import BaseRequest from oidc-op - const { default: BaseRequest } = await import('@solid/oidc-op/src/handlers/BaseRequest.js') - - // Create a mock request/response to test the redirect behavior - const mockReq = { - method: 'GET', - query: { - response_type: 'code', - redirect_uri: 'https://app.example.com/callback', - client_id: 'https://app.example.com', - state: 'test-state' - } - } - - const mockRes = { - redirectCalled: false, - redirectUrl: '', - redirect (url) { - this.redirectCalled = true - this.redirectUrl = url - } - } - - const request = new BaseRequest(mockReq, mockRes, oidc.provider) - request.params = mockReq.query - - // Simulate a successful authorization by calling redirect with auth data - try { - request.redirect({ code: 'test-auth-code' }) - } catch (err) { - // The redirect throws a HandledError, which is expected behavior - // We just need to check that the redirect was called with the right URL - } - - expect(mockRes.redirectCalled).to.be.true - expect(mockRes.redirectUrl).to.exist - - // Parse the redirect URL to check for iss parameter - const redirectUrl = new URL(mockRes.redirectUrl) - - // The iss parameter can be in either the query string or hash fragment - // depending on the response_mode (query or fragment) - let issParam = redirectUrl.searchParams.get('iss') - if (!issParam && redirectUrl.hash) { - // Check in the hash fragment - const hashParams = new URLSearchParams(redirectUrl.hash.substring(1)) - issParam = hashParams.get('iss') - } - - console.log('Redirect URL:', mockRes.redirectUrl) - console.log('RFC 9207 - iss parameter in redirect:', issParam) - - // RFC 9207: The iss parameter MUST be present and match the provider issuer - expect(issParam, 'RFC 9207: iss parameter must be present in authorization response').to.exist - expect(issParam).to.not.be.null - expect(issParam).to.equal(providerUri) - }) - }) +import { fileURLToPath } from 'url' +import path from 'path' +import { URL } from 'url' +import chai from 'chai' +import fs from 'fs-extra' +import { fromServerConfig } from '../../lib/models/oidc-manager.js' +import SolidHost from '../../lib/models/solid-host.js' + +const { expect } = chai + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const dbPath = path.join(__dirname, '../resources/.db') + +describe('OidcManager', () => { + beforeEach(() => { + fs.removeSync(dbPath) + }) + + describe('fromServerConfig()', () => { + it('should result in an initialized oidc object', () => { + const providerUri = 'https://localhost:8443' + const host = SolidHost.from({ providerUri }) + + const saltRounds = 5 + const argv = { + host, + dbPath, + saltRounds + } + + const oidc = fromServerConfig(argv) + + expect(oidc.rs.defaults.query).to.be.true + expect(oidc.clients.store.backend.path.endsWith('db/oidc/rp/clients')) + expect(oidc.provider.issuer).to.equal(providerUri) + expect(oidc.users.backend.path.endsWith('db/oidc/users')) + expect(oidc.users.saltRounds).to.equal(saltRounds) + }) + + it('should set the provider issuer which is used for iss claim in tokens', () => { + const providerUri = 'https://pivot-test.solidproject.org:8443' + const host = SolidHost.from({ serverUri: providerUri }) + + const saltRounds = 5 + const argv = { + host, + dbPath, + saltRounds + } + + const oidc = fromServerConfig(argv) + + // Verify the issuer is set correctly for RFC 9207 compliance + // The iss claim in tokens should match this issuer value + expect(oidc.provider.issuer).to.exist + expect(oidc.provider.issuer).to.not.be.null + expect(oidc.provider.issuer).to.equal(providerUri) + console.log('Provider issuer (used for iss claim):', oidc.provider.issuer) + }) + }) + + describe('RFC 9207 - Authorization redirect with iss parameter', () => { + it('should include iss parameter when redirecting after authorization', async () => { + const providerUri = 'https://localhost:8443' + const host = SolidHost.from({ providerUri }) + + const argv = { + host, + dbPath, + saltRounds: 5 + } + + const oidc = fromServerConfig(argv) + + // Dynamically import BaseRequest from oidc-op + const { default: BaseRequest } = await import('@solid/oidc-op/src/handlers/BaseRequest.js') + + // Create a mock request/response to test the redirect behavior + const mockReq = { + method: 'GET', + query: { + response_type: 'code', + redirect_uri: 'https://app.example.com/callback', + client_id: 'https://app.example.com', + state: 'test-state' + } + } + + const mockRes = { + redirectCalled: false, + redirectUrl: '', + redirect (url) { + this.redirectCalled = true + this.redirectUrl = url + } + } + + const request = new BaseRequest(mockReq, mockRes, oidc.provider) + request.params = mockReq.query + + // Simulate a successful authorization by calling redirect with auth data + try { + request.redirect({ code: 'test-auth-code' }) + } catch (err) { + // The redirect throws a HandledError, which is expected behavior + // We just need to check that the redirect was called with the right URL + } + + expect(mockRes.redirectCalled).to.be.true + expect(mockRes.redirectUrl).to.exist + + // Parse the redirect URL to check for iss parameter + const redirectUrl = new URL(mockRes.redirectUrl) + + // The iss parameter can be in either the query string or hash fragment + // depending on the response_mode (query or fragment) + let issParam = redirectUrl.searchParams.get('iss') + if (!issParam && redirectUrl.hash) { + // Check in the hash fragment + const hashParams = new URLSearchParams(redirectUrl.hash.substring(1)) + issParam = hashParams.get('iss') + } + + console.log('Redirect URL:', mockRes.redirectUrl) + console.log('RFC 9207 - iss parameter in redirect:', issParam) + + // RFC 9207: The iss parameter MUST be present and match the provider issuer + expect(issParam, 'RFC 9207: iss parameter must be present in authorization response').to.exist + expect(issParam).to.not.be.null + expect(issParam).to.equal(providerUri) + }) + }) }) diff --git a/test/integration/params-test.mjs b/test/integration/params-test.js similarity index 96% rename from test/integration/params-test.mjs rename to test/integration/params-test.js index d7ac3533c..30bb21d33 100644 --- a/test/integration/params-test.mjs +++ b/test/integration/params-test.js @@ -1,192 +1,192 @@ -import { describe, it, before, after } from 'mocha' -import { fileURLToPath } from 'url' -import fs from 'fs' -import path from 'path' -import { assert } from 'chai' -import supertest from 'supertest' - -// Import utilities from ESM version -import { rm, write, read, cleanDir, getTestRoot, setTestRoot } from '../utils.mjs' -import ldnode, { createServer } from '../../index.mjs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -// console.log(getTestRoot()) - -describe('LDNODE params', function () { - describe('suffixMeta', function () { - describe('not passed', function () { - after(function () { - // Clean up the sampleContainer directory after tests - const dirPath = path.join(process.cwd(), 'sampleContainer') - if (fs.existsSync(dirPath)) { - fs.rmSync(dirPath, { recursive: true, force: true }) - } - }) - it('should fallback on .meta', function () { - const ldp = ldnode({ webid: false }) - assert.equal(ldp.locals.ldp.suffixMeta, '.meta') - }) - }) - }) - - describe('suffixAcl', function () { - describe('not passed', function () { - it('should fallback on .acl', function () { - const ldp = ldnode({ webid: false }) - assert.equal(ldp.locals.ldp.suffixAcl, '.acl') - }) - }) - }) - - describe('root', function () { - describe('not passed', function () { - const ldp = ldnode({ webid: false }) - const server = supertest(ldp) - - it('should fallback on current working directory', function () { - assert.equal(path.normalize(ldp.locals.ldp.resourceMapper._rootPath), path.normalize(process.cwd())) - // console.log('Root path is', ldp.locals.ldp.resourceMapper._rootPath) - }) - - it('new : should find resource in correct path', function (done) { - const dirPath = path.join(process.cwd(), 'sampleContainer') - const ldp = ldnode({ dirPath, webid: false }) - const server = supertest(ldp) - const filePath = path.join(dirPath, 'example.ttl') - const fileContent = '<#current> <#temp> 123 .' - fs.mkdirSync(dirPath, { recursive: true }) - fs.writeFileSync(filePath, fileContent) - // console.log('Wrote file to', filePath) - server.get('/sampleContainer/example.ttl') - .expect('Link', /http:\/\/www.w3.org\/ns\/ldp#Resource/) - .expect(200) - .end(function (err, res, body) { - assert.equal(fs.readFileSync(filePath, 'utf8'), fileContent) - fs.unlinkSync(filePath) - done(err) - }) - }) - - it.skip('initial : should find resource in correct path', function (done) { - // Write to the default resources directory, matching the server's root - const resourcePath = path.join('sampleContainer', 'example.ttl') - // console.log('initial : Writing test resource to', resourcePath) - setTestRoot(path.join(__dirname, '../resources/')) - write('<#current> <#temp> 123 .', resourcePath) - - server.get('/test/resources/sampleContainer/example.ttl') - .expect('Link', /http:\/\/www.w3.org\/ns\/ldp#Resource/) - .expect(200) - .end(function (err, res, body) { - assert.equal(read(resourcePath), '<#current> <#temp> 123 .') - rm(resourcePath) - done(err) - }) - }) - }) - - describe('passed', function () { - const ldp = ldnode({ root: './test/resources/', webid: false }) - const server = supertest(ldp) - - it('should fallback on current working directory', function () { - assert.equal(path.normalize(ldp.locals.ldp.resourceMapper._rootPath), path.normalize(path.resolve('./test/resources'))) - }) - - it('new : should find resource in correct path', function (done) { - const ldp = createServer({ root: './test/resources/', webid: false }) - const server = supertest(ldp) - const dirPath = path.join(__dirname, '../resources/sampleContainer') - const filePath = path.join(dirPath, 'example.ttl') - const fileContent = '<#current> <#temp> 123 .' - fs.mkdirSync(dirPath, { recursive: true }) - fs.writeFileSync(filePath, fileContent) - // console.log('Wrote file to', filePath) - - server.get('/sampleContainer/example.ttl') - .expect('Link', /http:\/\/www.w3.org\/ns\/ldp#Resource/) - .expect(200) - .end(function (err, res, body) { - assert.equal(fs.readFileSync(filePath, 'utf8'), fileContent) - fs.unlinkSync(filePath) - done(err) - }) - }) - - it.skip('initial :should find resource in correct path', function (done) { - write( - '<#current> <#temp> 123 .', - '/sampleContainer/example.ttl') - - // This assumes npm test is run from the folder that contains package.js - server.get('/sampleContainer/example.ttl') - .expect('Link', /http:\/\/www.w3.org\/ns\/ldp#Resource/) - .expect(200) - .end(function (err, res, body) { - assert.equal(read('sampleContainer/example.ttl'), '<#current> <#temp> 123 .') - rm('sampleContainer/example.ttl') - done(err) - }) - }) - }) - }) - - describe('ui-path', function () { - const rootPath = './test/resources/' - const ldp = ldnode({ - root: rootPath, - apiApps: path.join(__dirname, '../resources/sampleContainer'), - webid: false - }) - const server = supertest(ldp) - - it('should serve static files on /api/ui', (done) => { - server.get('/api/apps/solid.png') - .expect(200) - .end(done) - }) - }) - - describe('forceUser', function () { - let ldpHttpsServer - - const port = 7777 - const serverUri = 'https://localhost:7777' - const rootPath = path.join(__dirname, '../resources/accounts-acl') - const dbPath = path.join(rootPath, 'db') - const configPath = path.join(rootPath, 'config') - - const ldp = createServer({ - auth: 'tls', - forceUser: 'https://fakeaccount.com/profile#me', - dbPath, - configPath, - serverUri, - port, - root: rootPath, - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), - webid: true, - host: 'localhost:3457', - rejectUnauthorized: false - }) - - before(function (done) { - ldpHttpsServer = ldp.listen(port, done) - }) - - after(function () { - if (ldpHttpsServer) ldpHttpsServer.close() - cleanDir(rootPath) - }) - - const server = supertest(serverUri) - - it('sets the User header', function (done) { - server.get('/hello.html') - .expect('User', 'https://fakeaccount.com/profile#me') - .end(done) - }) - }) -}) +import { describe, it, before, after } from 'mocha' +import { fileURLToPath } from 'url' +import fs from 'fs' +import path from 'path' +import { assert } from 'chai' +import supertest from 'supertest' + +// Import utilities from ESM version +import { rm, write, read, cleanDir, getTestRoot, setTestRoot } from '../utils.js' +import ldnode, { createServer } from '../../index.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +// console.log(getTestRoot()) + +describe('LDNODE params', function () { + describe('suffixMeta', function () { + describe('not passed', function () { + after(function () { + // Clean up the sampleContainer directory after tests + const dirPath = path.join(process.cwd(), 'sampleContainer') + if (fs.existsSync(dirPath)) { + fs.rmSync(dirPath, { recursive: true, force: true }) + } + }) + it('should fallback on .meta', function () { + const ldp = ldnode({ webid: false }) + assert.equal(ldp.locals.ldp.suffixMeta, '.meta') + }) + }) + }) + + describe('suffixAcl', function () { + describe('not passed', function () { + it('should fallback on .acl', function () { + const ldp = ldnode({ webid: false }) + assert.equal(ldp.locals.ldp.suffixAcl, '.acl') + }) + }) + }) + + describe('root', function () { + describe('not passed', function () { + const ldp = ldnode({ webid: false }) + const server = supertest(ldp) + + it('should fallback on current working directory', function () { + assert.equal(path.normalize(ldp.locals.ldp.resourceMapper._rootPath), path.normalize(process.cwd())) + // console.log('Root path is', ldp.locals.ldp.resourceMapper._rootPath) + }) + + it('new : should find resource in correct path', function (done) { + const dirPath = path.join(process.cwd(), 'sampleContainer') + const ldp = ldnode({ dirPath, webid: false }) + const server = supertest(ldp) + const filePath = path.join(dirPath, 'example.ttl') + const fileContent = '<#current> <#temp> 123 .' + fs.mkdirSync(dirPath, { recursive: true }) + fs.writeFileSync(filePath, fileContent) + // console.log('Wrote file to', filePath) + server.get('/sampleContainer/example.ttl') + .expect('Link', /http:\/\/www.w3.org\/ns\/ldp#Resource/) + .expect(200) + .end(function (err, res, body) { + assert.equal(fs.readFileSync(filePath, 'utf8'), fileContent) + fs.unlinkSync(filePath) + done(err) + }) + }) + + it.skip('initial : should find resource in correct path', function (done) { + // Write to the default resources directory, matching the server's root + const resourcePath = path.join('sampleContainer', 'example.ttl') + // console.log('initial : Writing test resource to', resourcePath) + setTestRoot(path.join(__dirname, '../resources/')) + write('<#current> <#temp> 123 .', resourcePath) + + server.get('/test/resources/sampleContainer/example.ttl') + .expect('Link', /http:\/\/www.w3.org\/ns\/ldp#Resource/) + .expect(200) + .end(function (err, res, body) { + assert.equal(read(resourcePath), '<#current> <#temp> 123 .') + rm(resourcePath) + done(err) + }) + }) + }) + + describe('passed', function () { + const ldp = ldnode({ root: './test/resources/', webid: false }) + const server = supertest(ldp) + + it('should fallback on current working directory', function () { + assert.equal(path.normalize(ldp.locals.ldp.resourceMapper._rootPath), path.normalize(path.resolve('./test/resources'))) + }) + + it('new : should find resource in correct path', function (done) { + const ldp = createServer({ root: './test/resources/', webid: false }) + const server = supertest(ldp) + const dirPath = path.join(__dirname, '../resources/sampleContainer') + const filePath = path.join(dirPath, 'example.ttl') + const fileContent = '<#current> <#temp> 123 .' + fs.mkdirSync(dirPath, { recursive: true }) + fs.writeFileSync(filePath, fileContent) + // console.log('Wrote file to', filePath) + + server.get('/sampleContainer/example.ttl') + .expect('Link', /http:\/\/www.w3.org\/ns\/ldp#Resource/) + .expect(200) + .end(function (err, res, body) { + assert.equal(fs.readFileSync(filePath, 'utf8'), fileContent) + fs.unlinkSync(filePath) + done(err) + }) + }) + + it.skip('initial :should find resource in correct path', function (done) { + write( + '<#current> <#temp> 123 .', + '/sampleContainer/example.ttl') + + // This assumes npm test is run from the folder that contains package.js + server.get('/sampleContainer/example.ttl') + .expect('Link', /http:\/\/www.w3.org\/ns\/ldp#Resource/) + .expect(200) + .end(function (err, res, body) { + assert.equal(read('sampleContainer/example.ttl'), '<#current> <#temp> 123 .') + rm('sampleContainer/example.ttl') + done(err) + }) + }) + }) + }) + + describe('ui-path', function () { + const rootPath = './test/resources/' + const ldp = ldnode({ + root: rootPath, + apiApps: path.join(__dirname, '../resources/sampleContainer'), + webid: false + }) + const server = supertest(ldp) + + it('should serve static files on /api/ui', (done) => { + server.get('/api/apps/solid.png') + .expect(200) + .end(done) + }) + }) + + describe('forceUser', function () { + let ldpHttpsServer + + const port = 7777 + const serverUri = 'https://localhost:7777' + const rootPath = path.join(__dirname, '../resources/accounts-acl') + const dbPath = path.join(rootPath, 'db') + const configPath = path.join(rootPath, 'config') + + const ldp = createServer({ + auth: 'tls', + forceUser: 'https://fakeaccount.com/profile#me', + dbPath, + configPath, + serverUri, + port, + root: rootPath, + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + webid: true, + host: 'localhost:3457', + rejectUnauthorized: false + }) + + before(function (done) { + ldpHttpsServer = ldp.listen(port, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + cleanDir(rootPath) + }) + + const server = supertest(serverUri) + + it('sets the User header', function (done) { + server.get('/hello.html') + .expect('User', 'https://fakeaccount.com/profile#me') + .end(done) + }) + }) +}) diff --git a/test/integration/patch-sparql-update-test.mjs b/test/integration/patch-sparql-update-test.js similarity index 96% rename from test/integration/patch-sparql-update-test.mjs rename to test/integration/patch-sparql-update-test.js index 1d9069bfc..bf020126e 100644 --- a/test/integration/patch-sparql-update-test.mjs +++ b/test/integration/patch-sparql-update-test.js @@ -1,195 +1,195 @@ -/* eslint-disable no-useless-escape */ -// ESM version of integration test for PATCH with application/sparql-update -import { describe, it, after } from 'mocha' -import { strict as assert } from 'assert' -import path from 'path' -import { fileURLToPath } from 'url' -import { rm, write, read } from '../utils.mjs' - -import ldnode from '../../index.mjs' -// import ldnode, { createServer } from '../../index.mjs' -import supertest from 'supertest' -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -before(function () { -}) - -describe('PATCH through application/sparql-update', function () { - // Starting LDP - // const ldp = ldnode.createServer({ - console.log('root: ' + path.join(__dirname, '../resources/sampleContainer')) - const ldp = ldnode({ - root: path.join(__dirname, '../resources/sampleContainer'), - mount: '/test-esm', - webid: false - }) - const server = supertest(ldp) - - it('should create a new file if file does not exist', function (done) { - rm('sampleContainer/notExisting.ttl') - // const sampleContainerPath = path.join(__dirname, '/resources/sampleContainer') - // fse.ensureDirSync(sampleContainerPath); - server.patch('/notExisting.ttl') - .set('content-type', 'application/sparql-update') - .send('INSERT DATA { :test :hello 456 .}') - .expect(201) - .end(function (err, res) { - assert.equal( - read('sampleContainer/notExisting.ttl'), - '@prefix : .\n\n:test :hello 456 .\n\n' - ) - rm('sampleContainer/notExisting.ttl') - done(err) - }) - }) - - describe('DELETE', function () { - it('should be an empty resource if last triple is deleted', function (done) { - write( - '<#current> <#temp> 123 .', - 'sampleContainer/existingTriple.ttl' - ) - server.post('/existingTriple.ttl') - .set('content-type', 'application/sparql-update') - .send('DELETE { :current :temp 123 .}') - .expect(200) - .end(function (err, res) { - assert.equal( - read('sampleContainer/existingTriple.ttl'), - '@prefix : .\n\n' - ) - rm('sampleContainer/existingTriple.ttl') - done(err) - }) - }) - - it('should delete a single triple from a pad document', function (done) { - const expected = '@prefix : .\n@prefix cal: .\n@prefix dc: .\n@prefix meeting: .\n@prefix pad: .\n@prefix sioc: .\n@prefix ui: .\n@prefix wf: .\n@prefix xsd: .\n@prefix c: .\n@prefix ind: .\n\n:id1477502276660 dc:author c:i; sioc:content \"\"; pad:next :this.\n\n:id1477522707481\n cal:dtstart \"2016-10-26T22:58:27Z\"^^xsd:dateTime;\n wf:participant c:i;\n ui:backgroundColor \"#c1d0c8\".\n:this\n a pad:Notepad;\n dc:author c:i;\n dc:created \"2016-10-25T15:44:42Z\"^^xsd:dateTime;\n dc:title \"Shared Notes\";\n pad:next :id1477502276660 .\nind:this wf:participation :id1477522707481; meeting:sharedNotes :this.\n\n' - write('\n\n @prefix dc: .\n @prefix mee: .\n @prefix c: .\n @prefix XML: .\n @prefix p: .\n @prefix ind: .\n @prefix n: .\n @prefix flow: .\n @prefix ic: .\n @prefix ui: .\n\n <#this>\n dc:author\n c:i;\n dc:created\n \"2016-10-25T15:44:42Z\"^^XML:dateTime;\n dc:title\n \"Shared Notes\";\n a p:Notepad;\n p:next\n <#id1477502276660>.\n ind:this flow:participation <#id1477522707481>; mee:sharedNotes <#this> .\n <#id1477502276660> dc:author c:i; n:content \"\"; p:indent 1; p:next <#this> .\n <#id1477522707481>\n ic:dtstart\n \"2016-10-26T22:58:27Z\"^^XML:dateTime;\n flow:participant\n c:i;\n ui:backgroundColor\n \"#c1d0c8\".\n', - 'sampleContainer/existingTriple.ttl' - ) - server.post('/existingTriple.ttl') - .set('content-type', 'application/sparql-update') - .send('DELETE { <#id1477502276660> 1 .}') - .expect(200) - .end(function (err, res) { - assert.equal( - read('sampleContainer/existingTriple.ttl'), - expected - ) - rm('sampleContainer/existingTriple.ttl') - done(err) - }) - }) - }) - - describe('DELETE and INSERT', function () { - after(() => rm('sampleContainer/prefixSparql.ttl')) - - it('should update a resource using SPARQL-query using `prefix`', function (done) { - write( - '@prefix schema: .\n' + - '@prefix pro: .\n' + - '# a schema:Person ;\n' + - '<#> a schema:Person ;\n' + - ' pro:first_name "Tim" .\n', - 'sampleContainer/prefixSparql.ttl' - ) - server.post('/prefixSparql.ttl') - .set('content-type', 'application/sparql-update') - .send('@prefix rdf: .\n' + - '@prefix schema: .\n' + - '@prefix pro: .\n' + - '@prefix ex: .\n' + - 'DELETE { <#> pro:first_name "Tim" }\n' + - 'INSERT { <#> pro:first_name "Timothy" }') - .expect(200) - .end(function (err, res) { - assert.equal( - read('sampleContainer/prefixSparql.ttl'), - '@prefix : .\n@prefix schema: .\n@prefix pro: .\n\n: a schema:Person; pro:first_name "Timothy".\n\n' - ) - done(err) - }) - }) - }) - - describe('INSERT', function () { - it('should add a new triple', function (done) { - write( - '<#current> <#temp> 123 .', - 'sampleContainer/addingTriple.ttl' - ) - server.post('/addingTriple.ttl') - .set('content-type', 'application/sparql-update') - .send('INSERT DATA { :test :hello 456 .}') - .expect(200) - .end(function (err, res) { - assert.equal( - read('sampleContainer/addingTriple.ttl'), - '@prefix : .\n\n:current :temp 123 .\n\n:test :hello 456 .\n\n' - ) - rm('sampleContainer/addingTriple.ttl') - done(err) - }) - }) - - it('should add value to existing triple', function (done) { - write( - '<#current> <#temp> 123 .', - 'sampleContainer/addingTripleValue.ttl' - ) - server.post('/addingTripleValue.ttl') - .set('content-type', 'application/sparql-update') - .send('INSERT DATA { :current :temp 456 .}') - .expect(200) - .end(function (err, res) { - assert.equal( - read('sampleContainer/addingTripleValue.ttl'), - '@prefix : .\n\n:current :temp 123, 456 .\n\n' - ) - rm('sampleContainer/addingTripleValue.ttl') - done(err) - }) - }) - - it('should add value to same subject', function (done) { - write( - '<#current> <#temp> 123 .', - 'sampleContainer/addingTripleSubj.ttl' - ) - server.post('/addingTripleSubj.ttl') - .set('content-type', 'application/sparql-update') - .send('INSERT DATA { :current :temp2 456 .}') - .expect(200) - .end(function (err, res) { - assert.equal( - read('sampleContainer/addingTripleSubj.ttl'), - '@prefix : .\n\n:current :temp 123; :temp2 456 .\n\n' - ) - rm('sampleContainer/addingTripleSubj.ttl') - done(err) - }) - }) - }) - - it('nothing should change with empty patch', function (done) { - write( - '<#current> <#temp> 123 .', - 'sampleContainer/emptyExample.ttl' - ) - server.post('/emptyExample.ttl') - .set('content-type', 'application/sparql-update') - .send('') - .expect(200) - .end(function (err, res) { - assert.equal( - read('sampleContainer/emptyExample.ttl'), - '@prefix : .\n\n:current :temp 123 .\n\n' - ) - rm('sampleContainer/emptyExample.ttl') - done(err) - }) - }) -}) +/* eslint-disable no-useless-escape */ +// ESM version of integration test for PATCH with application/sparql-update +import { describe, it, after } from 'mocha' +import { strict as assert } from 'assert' +import path from 'path' +import { fileURLToPath } from 'url' +import { rm, write, read } from '../utils.js' + +import ldnode from '../../index.js' +// import ldnode, { createServer } from '../../index.js' +import supertest from 'supertest' +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +before(function () { +}) + +describe('PATCH through application/sparql-update', function () { + // Starting LDP + // const ldp = ldnode.createServer({ + console.log('root: ' + path.join(__dirname, '../resources/sampleContainer')) + const ldp = ldnode({ + root: path.join(__dirname, '../resources/sampleContainer'), + mount: '/test-esm', + webid: false + }) + const server = supertest(ldp) + + it('should create a new file if file does not exist', function (done) { + rm('sampleContainer/notExisting.ttl') + // const sampleContainerPath = path.join(__dirname, '/resources/sampleContainer') + // fse.ensureDirSync(sampleContainerPath); + server.patch('/notExisting.ttl') + .set('content-type', 'application/sparql-update') + .send('INSERT DATA { :test :hello 456 .}') + .expect(201) + .end(function (err, res) { + assert.equal( + read('sampleContainer/notExisting.ttl'), + '@prefix : .\n\n:test :hello 456 .\n\n' + ) + rm('sampleContainer/notExisting.ttl') + done(err) + }) + }) + + describe('DELETE', function () { + it('should be an empty resource if last triple is deleted', function (done) { + write( + '<#current> <#temp> 123 .', + 'sampleContainer/existingTriple.ttl' + ) + server.post('/existingTriple.ttl') + .set('content-type', 'application/sparql-update') + .send('DELETE { :current :temp 123 .}') + .expect(200) + .end(function (err, res) { + assert.equal( + read('sampleContainer/existingTriple.ttl'), + '@prefix : .\n\n' + ) + rm('sampleContainer/existingTriple.ttl') + done(err) + }) + }) + + it('should delete a single triple from a pad document', function (done) { + const expected = '@prefix : .\n@prefix cal: .\n@prefix dc: .\n@prefix meeting: .\n@prefix pad: .\n@prefix sioc: .\n@prefix ui: .\n@prefix wf: .\n@prefix xsd: .\n@prefix c: .\n@prefix ind: .\n\n:id1477502276660 dc:author c:i; sioc:content \"\"; pad:next :this.\n\n:id1477522707481\n cal:dtstart \"2016-10-26T22:58:27Z\"^^xsd:dateTime;\n wf:participant c:i;\n ui:backgroundColor \"#c1d0c8\".\n:this\n a pad:Notepad;\n dc:author c:i;\n dc:created \"2016-10-25T15:44:42Z\"^^xsd:dateTime;\n dc:title \"Shared Notes\";\n pad:next :id1477502276660 .\nind:this wf:participation :id1477522707481; meeting:sharedNotes :this.\n\n' + write('\n\n @prefix dc: .\n @prefix mee: .\n @prefix c: .\n @prefix XML: .\n @prefix p: .\n @prefix ind: .\n @prefix n: .\n @prefix flow: .\n @prefix ic: .\n @prefix ui: .\n\n <#this>\n dc:author\n c:i;\n dc:created\n \"2016-10-25T15:44:42Z\"^^XML:dateTime;\n dc:title\n \"Shared Notes\";\n a p:Notepad;\n p:next\n <#id1477502276660>.\n ind:this flow:participation <#id1477522707481>; mee:sharedNotes <#this> .\n <#id1477502276660> dc:author c:i; n:content \"\"; p:indent 1; p:next <#this> .\n <#id1477522707481>\n ic:dtstart\n \"2016-10-26T22:58:27Z\"^^XML:dateTime;\n flow:participant\n c:i;\n ui:backgroundColor\n \"#c1d0c8\".\n', + 'sampleContainer/existingTriple.ttl' + ) + server.post('/existingTriple.ttl') + .set('content-type', 'application/sparql-update') + .send('DELETE { <#id1477502276660> 1 .}') + .expect(200) + .end(function (err, res) { + assert.equal( + read('sampleContainer/existingTriple.ttl'), + expected + ) + rm('sampleContainer/existingTriple.ttl') + done(err) + }) + }) + }) + + describe('DELETE and INSERT', function () { + after(() => rm('sampleContainer/prefixSparql.ttl')) + + it('should update a resource using SPARQL-query using `prefix`', function (done) { + write( + '@prefix schema: .\n' + + '@prefix pro: .\n' + + '# a schema:Person ;\n' + + '<#> a schema:Person ;\n' + + ' pro:first_name "Tim" .\n', + 'sampleContainer/prefixSparql.ttl' + ) + server.post('/prefixSparql.ttl') + .set('content-type', 'application/sparql-update') + .send('@prefix rdf: .\n' + + '@prefix schema: .\n' + + '@prefix pro: .\n' + + '@prefix ex: .\n' + + 'DELETE { <#> pro:first_name "Tim" }\n' + + 'INSERT { <#> pro:first_name "Timothy" }') + .expect(200) + .end(function (err, res) { + assert.equal( + read('sampleContainer/prefixSparql.ttl'), + '@prefix : .\n@prefix schema: .\n@prefix pro: .\n\n: a schema:Person; pro:first_name "Timothy".\n\n' + ) + done(err) + }) + }) + }) + + describe('INSERT', function () { + it('should add a new triple', function (done) { + write( + '<#current> <#temp> 123 .', + 'sampleContainer/addingTriple.ttl' + ) + server.post('/addingTriple.ttl') + .set('content-type', 'application/sparql-update') + .send('INSERT DATA { :test :hello 456 .}') + .expect(200) + .end(function (err, res) { + assert.equal( + read('sampleContainer/addingTriple.ttl'), + '@prefix : .\n\n:current :temp 123 .\n\n:test :hello 456 .\n\n' + ) + rm('sampleContainer/addingTriple.ttl') + done(err) + }) + }) + + it('should add value to existing triple', function (done) { + write( + '<#current> <#temp> 123 .', + 'sampleContainer/addingTripleValue.ttl' + ) + server.post('/addingTripleValue.ttl') + .set('content-type', 'application/sparql-update') + .send('INSERT DATA { :current :temp 456 .}') + .expect(200) + .end(function (err, res) { + assert.equal( + read('sampleContainer/addingTripleValue.ttl'), + '@prefix : .\n\n:current :temp 123, 456 .\n\n' + ) + rm('sampleContainer/addingTripleValue.ttl') + done(err) + }) + }) + + it('should add value to same subject', function (done) { + write( + '<#current> <#temp> 123 .', + 'sampleContainer/addingTripleSubj.ttl' + ) + server.post('/addingTripleSubj.ttl') + .set('content-type', 'application/sparql-update') + .send('INSERT DATA { :current :temp2 456 .}') + .expect(200) + .end(function (err, res) { + assert.equal( + read('sampleContainer/addingTripleSubj.ttl'), + '@prefix : .\n\n:current :temp 123; :temp2 456 .\n\n' + ) + rm('sampleContainer/addingTripleSubj.ttl') + done(err) + }) + }) + }) + + it('nothing should change with empty patch', function (done) { + write( + '<#current> <#temp> 123 .', + 'sampleContainer/emptyExample.ttl' + ) + server.post('/emptyExample.ttl') + .set('content-type', 'application/sparql-update') + .send('') + .expect(200) + .end(function (err, res) { + assert.equal( + read('sampleContainer/emptyExample.ttl'), + '@prefix : .\n\n:current :temp 123 .\n\n' + ) + rm('sampleContainer/emptyExample.ttl') + done(err) + }) + }) +}) diff --git a/test/integration/patch-test.mjs b/test/integration/patch-test.js similarity index 96% rename from test/integration/patch-test.mjs rename to test/integration/patch-test.js index 3ddebfa43..570b855e1 100644 --- a/test/integration/patch-test.mjs +++ b/test/integration/patch-test.js @@ -1,590 +1,590 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import fs from 'fs' -// Import utility functions from the ESM utils -import { read, rm, backup, restore } from '../utils.mjs' -import { assert } from 'chai' -import supertest from 'supertest' - -import ldnode from '../../index.mjs' -// import ldnode from '../../index.mjs'; - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -// Server settings -const port = 7777 -const serverUri = `https://tim.localhost:${port}` -const root = path.join(__dirname, '../resources/patch') -const configPath = path.join(__dirname, '../resources/config') -const serverOptions = { - root, - configPath, - serverUri, - multiuser: false, - webid: true, - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), - forceUser: `${serverUri}/profile/card#me` -} - -describe('PATCH through text/n3', () => { - let request - let server - - // Start the server - before(done => { - server = ldnode.createServer(serverOptions) - server.listen(port, done) - request = supertest(serverUri) - // console.log('Server started at ' + serverUri) - }) - - // Stop the server - after(() => { - server.close() - }) - - after(() => { - server.close() - }) - - describe('with a patch document', () => { - describe('with an unsupported content type', describePatch({ - path: '/read-write.ttl', - patch: 'other syntax', - contentType: 'text/other' - }, { // expected: - status: 415, - text: 'Unsupported patch content type: text/other' - })) - - describe('containing invalid syntax', describePatch({ - path: '/read-write.ttl', - patch: 'invalid syntax' - }, { // expected: - status: 400, - text: 'Patch document syntax error' - })) - - describe('without relevant patch element', describePatch({ - path: '/read-write.ttl', - patch: '<> a solid:Patch.' - }, { // expected: - status: 400, - text: 'No n3-patch found' - })) - - describe('with neither insert nor delete', describePatch({ - path: '/read-write.ttl', - patch: '<> a solid:InsertDeletePatch.' - }, { // expected: - status: 400, - text: 'Patch should at least contain inserts or deletes' - })) - }) - - describe('with insert', () => { - describe('on a non-existing file', describePatch({ - path: '/new.ttl', - exists: false, - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }.` - }, { // expected: - status: 201, - text: 'Patch applied successfully', - // result: '@prefix : .\n@prefix tim: .\n\ntim:x tim:y tim:z.\n\n' - result: '@prefix : .\n\n .\n\n' - })) - - describe('on a non-existent JSON-LD file', describePatch({ - path: '/new.jsonld', - exists: false, - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }.` - }, { // expected: - status: 201, - text: 'Patch applied successfully', - // result: '{\n "@id": "/x",\n "/y": {\n "@id": "/z"\n }\n}' - /* result: `{ - "@context": { - "tim": "https://tim.localhost:7777/" - }, - "@id": "tim:x", - "tim:y": { - "@id": "tim:z" - } -}` */ - result: `{ - "@id": "https://tim.localhost:7777/x", - "https://tim.localhost:7777/y": { - "@id": "https://tim.localhost:7777/z" - } -}` - })) - - describe('on a non-existent RDF+XML file', describePatch({ - path: '/new.rdf', - exists: false, - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }.` - }, { // expected: - status: 201, - text: 'Patch applied successfully', - result: ` - - -` - })) - - describe('on a non-existent N3 file', describePatch({ - path: '/new.n3', - exists: false, - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }.` - }, { // expected: - status: 201, - text: 'Patch applied successfully', - // result: '@prefix : .\n@prefix tim: .\n\ntim:x tim:y tim:z.\n\n' - result: '@prefix : .\n\n .\n\n' - })) - - describe('on an N3 file that has an invalid uri (*.acl)', describePatch({ - path: '/foo/bar.acl/test.n3', - exists: false, - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }.` - }, { - status: 400, - text: 'contained reserved suffixes in path' - })) - - describe('on an N3 file that has an invalid uri (*.meta)', describePatch({ - path: '/foo/bar/xyz.meta/test.n3', - exists: false, - patch: `<> a solid:InsertDeletePatch; - solid:insers { . }.` - }, { - status: 400, - text: 'contained reserved suffixes in path' - })) - - describe('on a resource with read-only access', describePatch({ - path: '/read-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }.` - }, { // expected: - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with append-only access', describePatch({ - path: '/append-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }.` - }, { // expected: - status: 200, - text: 'Patch applied successfully', - // result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n' - result: '@prefix : .\n\n .\n\n .\n\n .\n\n' - })) - - describe('on a resource with write-only access', describePatch({ - path: '/write-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }.` - }, { // expected: - status: 200, - text: 'Patch applied successfully', - // result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n' - result: '@prefix : .\n\n .\n\n .\n\n .\n\n' - })) - - describe('on a resource with parent folders that do not exist', describePatch({ - path: '/folder/cool.ttl', - exists: false, - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }.` - }, { - status: 201, - text: 'Patch applied successfully', - // result: '@prefix : <#>.\n@prefix fol: <./>.\n\nfol:x fol:y fol:z.\n\n' - result: '@prefix : <#>.\n\n .\n\n' - })) - }) - - describe('with insert and where', () => { - describe('on a non-existing file', describePatch({ - path: '/new.ttl', - exists: false, - patch: `<> a solid:InsertDeletePatch; - solid:inserts { ?a . }; - solid:where { ?a . }.` - }, { // expected: - status: 409, - text: 'The patch could not be applied' - })) - - describe('on a resource with read-only access', describePatch({ - path: '/read-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { ?a . }; - solid:where { ?a . }.` - }, { // expected: - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with append-only access', describePatch({ - path: '/append-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { ?a . }; - solid:where { ?a . }.` - }, { // expected: - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with write-only access', describePatch({ - path: '/write-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { ?a . }; - solid:where { ?a . }.` - }, { // expected: - // Allowing the insert would either return 200 or 409, - // thereby inappropriately giving the user (guess-based) read access; - // therefore, we need to return 403. - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with read-append access', () => { - describe('with a matching WHERE clause', describePatch({ - path: '/read-append.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { ?a . }; - solid:where { ?a . }.` - }, { // expected: - status: 200, - text: 'Patch applied successfully', - // result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c; tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n' - result: '@prefix : .\n\n ; .\n\n .\n\n' - })) - - describe('with a non-matching WHERE clause', describePatch({ - path: '/read-append.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:where { ?a . }; - solid:inserts { ?a . }.` - }, { // expected: - status: 409, - text: 'The patch could not be applied' - })) - }) - - describe('on a resource with read-write access', () => { - describe('with a matching WHERE clause', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { ?a . }; - solid:where { ?a . }.` - }, { // expected: - status: 200, - text: 'Patch applied successfully', - // result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c; tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n' +import { fileURLToPath } from 'url' +import path from 'path' +import fs from 'fs' +// Import utility functions from the ESM utils +import { read, rm, backup, restore } from '../utils.js' +import { assert } from 'chai' +import supertest from 'supertest' + +import ldnode from '../../index.js' +// import ldnode from '../../index.js'; + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Server settings +const port = 7777 +const serverUri = `https://tim.localhost:${port}` +const root = path.join(__dirname, '../resources/patch') +const configPath = path.join(__dirname, '../resources/config') +const serverOptions = { + root, + configPath, + serverUri, + multiuser: false, + webid: true, + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + forceUser: `${serverUri}/profile/card#me` +} + +describe('PATCH through text/n3', () => { + let request + let server + + // Start the server + before(done => { + server = ldnode.createServer(serverOptions) + server.listen(port, done) + request = supertest(serverUri) + // console.log('Server started at ' + serverUri) + }) + + // Stop the server + after(() => { + server.close() + }) + + after(() => { + server.close() + }) + + describe('with a patch document', () => { + describe('with an unsupported content type', describePatch({ + path: '/read-write.ttl', + patch: 'other syntax', + contentType: 'text/other' + }, { // expected: + status: 415, + text: 'Unsupported patch content type: text/other' + })) + + describe('containing invalid syntax', describePatch({ + path: '/read-write.ttl', + patch: 'invalid syntax' + }, { // expected: + status: 400, + text: 'Patch document syntax error' + })) + + describe('without relevant patch element', describePatch({ + path: '/read-write.ttl', + patch: '<> a solid:Patch.' + }, { // expected: + status: 400, + text: 'No n3-patch found' + })) + + describe('with neither insert nor delete', describePatch({ + path: '/read-write.ttl', + patch: '<> a solid:InsertDeletePatch.' + }, { // expected: + status: 400, + text: 'Patch should at least contain inserts or deletes' + })) + }) + + describe('with insert', () => { + describe('on a non-existing file', describePatch({ + path: '/new.ttl', + exists: false, + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }.` + }, { // expected: + status: 201, + text: 'Patch applied successfully', + // result: '@prefix : .\n@prefix tim: .\n\ntim:x tim:y tim:z.\n\n' + result: '@prefix : .\n\n .\n\n' + })) + + describe('on a non-existent JSON-LD file', describePatch({ + path: '/new.jsonld', + exists: false, + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }.` + }, { // expected: + status: 201, + text: 'Patch applied successfully', + // result: '{\n "@id": "/x",\n "/y": {\n "@id": "/z"\n }\n}' + /* result: `{ + "@context": { + "tim": "https://tim.localhost:7777/" + }, + "@id": "tim:x", + "tim:y": { + "@id": "tim:z" + } +}` */ + result: `{ + "@id": "https://tim.localhost:7777/x", + "https://tim.localhost:7777/y": { + "@id": "https://tim.localhost:7777/z" + } +}` + })) + + describe('on a non-existent RDF+XML file', describePatch({ + path: '/new.rdf', + exists: false, + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }.` + }, { // expected: + status: 201, + text: 'Patch applied successfully', + result: ` + + +` + })) + + describe('on a non-existent N3 file', describePatch({ + path: '/new.n3', + exists: false, + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }.` + }, { // expected: + status: 201, + text: 'Patch applied successfully', + // result: '@prefix : .\n@prefix tim: .\n\ntim:x tim:y tim:z.\n\n' + result: '@prefix : .\n\n .\n\n' + })) + + describe('on an N3 file that has an invalid uri (*.acl)', describePatch({ + path: '/foo/bar.acl/test.n3', + exists: false, + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }.` + }, { + status: 400, + text: 'contained reserved suffixes in path' + })) + + describe('on an N3 file that has an invalid uri (*.meta)', describePatch({ + path: '/foo/bar/xyz.meta/test.n3', + exists: false, + patch: `<> a solid:InsertDeletePatch; + solid:insers { . }.` + }, { + status: 400, + text: 'contained reserved suffixes in path' + })) + + describe('on a resource with read-only access', describePatch({ + path: '/read-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }.` + }, { // expected: + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with append-only access', describePatch({ + path: '/append-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + // result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n' + result: '@prefix : .\n\n .\n\n .\n\n .\n\n' + })) + + describe('on a resource with write-only access', describePatch({ + path: '/write-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + // result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c.\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n' + result: '@prefix : .\n\n .\n\n .\n\n .\n\n' + })) + + describe('on a resource with parent folders that do not exist', describePatch({ + path: '/folder/cool.ttl', + exists: false, + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }.` + }, { + status: 201, + text: 'Patch applied successfully', + // result: '@prefix : <#>.\n@prefix fol: <./>.\n\nfol:x fol:y fol:z.\n\n' + result: '@prefix : <#>.\n\n .\n\n' + })) + }) + + describe('with insert and where', () => { + describe('on a non-existing file', describePatch({ + path: '/new.ttl', + exists: false, + patch: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('on a resource with read-only access', describePatch({ + path: '/read-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` + }, { // expected: + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with append-only access', describePatch({ + path: '/append-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` + }, { // expected: + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with write-only access', describePatch({ + path: '/write-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` + }, { // expected: + // Allowing the insert would either return 200 or 409, + // thereby inappropriately giving the user (guess-based) read access; + // therefore, we need to return 403. + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with read-append access', () => { + describe('with a matching WHERE clause', describePatch({ + path: '/read-append.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + // result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c; tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n' + result: '@prefix : .\n\n ; .\n\n .\n\n' + })) + + describe('with a non-matching WHERE clause', describePatch({ + path: '/read-append.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:inserts { ?a . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + }) + + describe('on a resource with read-write access', () => { + describe('with a matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { ?a . }; + solid:where { ?a . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + // result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:b tim:c; tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n' result: '@prefix : .\n\n ; .\n\n .\n\n' - })) - - describe('with a non-matching WHERE clause', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:where { ?a . }; - solid:inserts { ?a . }.` - }, { // expected: - status: 409, - text: 'The patch could not be applied' - })) - }) - }) - - describe('with delete', () => { - describe('on a non-existing file', describePatch({ - path: '/new.ttl', - exists: false, - patch: `<> a solid:InsertDeletePatch; - solid:deletes { . }.` - }, { // expected: - status: 409, - text: 'The patch could not be applied' - })) - - describe('on a resource with read-only access', describePatch({ - path: '/read-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:deletes { . }.` - }, { // expected: - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with append-only access', describePatch({ - path: '/append-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:deletes { . }.` - }, { // expected: - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with write-only access', describePatch({ - path: '/write-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:deletes { . }.` - }, { // expected: - // Allowing the delete would either return 200 or 409, - // thereby inappropriately giving the user (guess-based) read access; - // therefore, we need to return 403. - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with read-append access', describePatch({ - path: '/read-append.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:deletes { . }.` - }, { // expected: - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with read-write access', () => { - describe('with a patch for existing data', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:deletes { . }.` - }, { // expected: - status: 200, - text: 'Patch applied successfully', - // result: '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n' + })) + + describe('with a non-matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:inserts { ?a . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + }) + }) + + describe('with delete', () => { + describe('on a non-existing file', describePatch({ + path: '/new.ttl', + exists: false, + patch: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('on a resource with read-only access', describePatch({ + path: '/read-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` + }, { // expected: + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with append-only access', describePatch({ + path: '/append-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` + }, { // expected: + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with write-only access', describePatch({ + path: '/write-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` + }, { // expected: + // Allowing the delete would either return 200 or 409, + // thereby inappropriately giving the user (guess-based) read access; + // therefore, we need to return 403. + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with read-append access', describePatch({ + path: '/read-append.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` + }, { // expected: + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with read-write access', () => { + describe('with a patch for existing data', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + // result: '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n' result: '@prefix : .\n\n .\n\n' - })) - - describe('with a patch for non-existing data', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:deletes { . }.` - }, { // expected: - status: 409, - text: 'The patch could not be applied' - })) - - describe('with a matching WHERE clause', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:where { ?a . }; - solid:deletes { ?a . }.` - }, { // expected: - status: 200, - text: 'Patch applied successfully', - // result: '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n' + })) + + describe('with a patch for non-existing data', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:deletes { . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('with a matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:deletes { ?a . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + // result: '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\n' result: '@prefix : .\n\n .\n\n' - })) - - describe('with a non-matching WHERE clause', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:where { ?a . }; - solid:deletes { ?a . }.` - }, { // expected: - status: 409, - text: 'The patch could not be applied' - })) - }) - }) - - describe('deleting and inserting', () => { - describe('on a non-existing file', describePatch({ - path: '/new.ttl', - exists: false, - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }; - solid:deletes { . }.` - }, { // expected: - status: 409, - text: 'The patch could not be applied' - })) - - describe('on a resource with read-only access', describePatch({ - path: '/read-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }; - solid:deletes { . }.` - }, { // expected: - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with append-only access', describePatch({ - path: '/append-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }; - solid:deletes { . }.` - }, { // expected: - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with write-only access', describePatch({ - path: '/write-only.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }; - solid:deletes { . }.` - }, { // expected: - // Allowing the delete would either return 200 or 409, - // thereby inappropriately giving the user (guess-based) read access; - // therefore, we need to return 403. - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with read-append access', describePatch({ - path: '/read-append.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }; - solid:deletes { . }.` - }, { // expected: - status: 403, - text: 'GlobalDashboard' - })) - - describe('on a resource with read-write access', () => { - describe('executes deletes before inserts', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }; - solid:deletes { . }.` - }, { // expected: - status: 409, - text: 'The patch could not be applied' - })) - - describe('with a patch for existing data', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }; - solid:deletes { . }.` - }, { // expected: - status: 200, - text: 'Patch applied successfully', - // result: '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n' + })) + + describe('with a non-matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:deletes { ?a . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + }) + }) + + describe('deleting and inserting', () => { + describe('on a non-existing file', describePatch({ + path: '/new.ttl', + exists: false, + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('on a resource with read-only access', describePatch({ + path: '/read-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` + }, { // expected: + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with append-only access', describePatch({ + path: '/append-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` + }, { // expected: + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with write-only access', describePatch({ + path: '/write-only.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` + }, { // expected: + // Allowing the delete would either return 200 or 409, + // thereby inappropriately giving the user (guess-based) read access; + // therefore, we need to return 403. + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with read-append access', describePatch({ + path: '/read-append.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` + }, { // expected: + status: 403, + text: 'GlobalDashboard' + })) + + describe('on a resource with read-write access', () => { + describe('executes deletes before inserts', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('with a patch for existing data', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + // result: '@prefix : .\n@prefix tim: .\n\ntim:d tim:e tim:f.\n\ntim:x tim:y tim:z.\n\n' result: '@prefix : .\n\n .\n\n .\n\n' - })) - - describe('with a patch for non-existing data', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:inserts { . }; - solid:deletes { . }.` - }, { // expected: - status: 409, - text: 'The patch could not be applied' - })) - - describe('with a matching WHERE clause', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:where { ?a . }; - solid:inserts { ?a . }; - solid:deletes { ?a . }.` - }, { // expected: - status: 200, - text: 'Patch applied successfully', - // result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n' + })) + + describe('with a patch for non-existing data', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:inserts { . }; + solid:deletes { . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + + describe('with a matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:inserts { ?a . }; + solid:deletes { ?a . }.` + }, { // expected: + status: 200, + text: 'Patch applied successfully', + // result: '@prefix : .\n@prefix tim: .\n\ntim:a tim:y tim:z.\n\ntim:d tim:e tim:f.\n\n' result: '@prefix : .\n\n .\n\n .\n\n' - })) - - describe('with a non-matching WHERE clause', describePatch({ - path: '/read-write.ttl', - patch: `<> a solid:InsertDeletePatch; - solid:where { ?a . }; - solid:inserts { ?a . }; - solid:deletes { ?a . }.` - }, { // expected: - status: 409, - text: 'The patch could not be applied' - })) - }) - }) - - // Creates a PATCH test for the given resource with the given expected outcomes - function describePatch ({ path, exists = true, patch, contentType = 'text/n3' }, - { status = 200, text, result }) { - return () => { - const filename = `patch${path}` - let originalContents - // Back up and restore an existing file - if (exists) { - before(() => backup(filename)) - after(() => restore(filename)) - // Store its contents to verify non-modification - if (!result) { - originalContents = read(filename) - } - // Ensure a non-existing file is removed - } else { - before(() => rm(filename)) - after(() => rm(filename)) - } - - // Create the request and obtain the response - let response - before((done) => { - request.patch(path) - .set('Content-Type', contentType) - .send(`@prefix solid: .\n${patch}`) - .then(res => { response = res }) - .then(done, done) - }) - - // Verify the response's status code and body text - it(`returns HTTP status code ${status}`, () => { - assert.isObject(response) - assert.equal(response.statusCode, status) - }) - it(`has "${text}" in the response`, () => { - assert.isObject(response) - assert.include(response.text, text) - }) - - // For existing files, verify correct patch application - if (exists) { - if (result) { - it('patches the file correctly', () => { - assert.equal(read(filename), result) - }) - } else { - it('does not modify the file', () => { - assert.equal(read(filename), originalContents) - }) - } - // For non-existing files, verify creation and contents - } else { - if (result) { - it('creates the file', () => { - assert.isTrue(fs.existsSync(`${root}/${path}`)) - }) - - it('writes the correct contents', () => { - assert.equal(read(filename), result) - }) - } else { - it('does not create the file', () => { - assert.isFalse(fs.existsSync(`${root}/${path}`)) - }) - } - } - } - } -}) + })) + + describe('with a non-matching WHERE clause', describePatch({ + path: '/read-write.ttl', + patch: `<> a solid:InsertDeletePatch; + solid:where { ?a . }; + solid:inserts { ?a . }; + solid:deletes { ?a . }.` + }, { // expected: + status: 409, + text: 'The patch could not be applied' + })) + }) + }) + + // Creates a PATCH test for the given resource with the given expected outcomes + function describePatch ({ path, exists = true, patch, contentType = 'text/n3' }, + { status = 200, text, result }) { + return () => { + const filename = `patch${path}` + let originalContents + // Back up and restore an existing file + if (exists) { + before(() => backup(filename)) + after(() => restore(filename)) + // Store its contents to verify non-modification + if (!result) { + originalContents = read(filename) + } + // Ensure a non-existing file is removed + } else { + before(() => rm(filename)) + after(() => rm(filename)) + } + + // Create the request and obtain the response + let response + before((done) => { + request.patch(path) + .set('Content-Type', contentType) + .send(`@prefix solid: .\n${patch}`) + .then(res => { response = res }) + .then(done, done) + }) + + // Verify the response's status code and body text + it(`returns HTTP status code ${status}`, () => { + assert.isObject(response) + assert.equal(response.statusCode, status) + }) + it(`has "${text}" in the response`, () => { + assert.isObject(response) + assert.include(response.text, text) + }) + + // For existing files, verify correct patch application + if (exists) { + if (result) { + it('patches the file correctly', () => { + assert.equal(read(filename), result) + }) + } else { + it('does not modify the file', () => { + assert.equal(read(filename), originalContents) + }) + } + // For non-existing files, verify creation and contents + } else { + if (result) { + it('creates the file', () => { + assert.isTrue(fs.existsSync(`${root}/${path}`)) + }) + + it('writes the correct contents', () => { + assert.equal(read(filename), result) + }) + } else { + it('does not create the file', () => { + assert.isFalse(fs.existsSync(`${root}/${path}`)) + }) + } + } + } + } +}) diff --git a/test/integration/payment-pointer-test.mjs b/test/integration/payment-pointer-test.js similarity index 94% rename from test/integration/payment-pointer-test.mjs rename to test/integration/payment-pointer-test.js index 4da9a3ba0..17f9a1290 100644 --- a/test/integration/payment-pointer-test.mjs +++ b/test/integration/payment-pointer-test.js @@ -1,155 +1,155 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import supertest from 'supertest' -import chai from 'chai' - -// Import utility functions from the ESM utils -import { cleanDir } from '../utils.mjs' -import * as Solid from '../../index.mjs' - -const { expect } = chai - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -describe('API', () => { - const configPath = path.join(__dirname, '../resources/config') - - const serverConfig = { - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), - auth: 'oidc', - dataBrowser: false, - webid: true, - multiuser: false, - configPath - } - - function startServer (pod, port) { - return new Promise((resolve) => { - pod.listen(port, () => { resolve() }) - }) - } - - describe('Payment Pointer Alice', () => { - let alice - const aliceServerUri = 'https://localhost:5000' - const aliceDbPath = path.join(__dirname, - '../resources/accounts-scenario/alice/db') - const aliceRootPath = path.join(__dirname, '../resources/accounts-scenario/alice') - - const alicePod = Solid.createServer( - Object.assign({ - root: aliceRootPath, - serverUri: aliceServerUri, - dbPath: aliceDbPath - }, serverConfig) - ) - - before(() => { - return Promise.all([ - startServer(alicePod, 5000) - ]).then(() => { - alice = supertest(aliceServerUri) - }) - }) - - after(() => { - alicePod.close() - cleanDir(aliceRootPath) - }) - - describe('GET Payment Pointer document', () => { - it('should show instructions to add a triple', (done) => { - alice.get('/.well-known/pay') - .expect(200) - .expect('content-type', /application\/json/) - .end(function (err, req) { - if (err) { - done(err) - } else { - expect(req.body).deep.equal({ - fail: 'Add triple', - subject: '', - predicate: '', - object: '$alice.example' - }) - done() - } - }) - }) - }) - }) - - describe('Payment Pointer Bob', () => { - let bob - const bobServerUri = 'https://localhost:5001' - const bobDbPath = path.join(__dirname, - '../resources/accounts-scenario/bob/db') - const bobRootPath = path.join(__dirname, '../resources/accounts-scenario/bob') - const bobPod = Solid.createServer( - Object.assign({ - root: bobRootPath, - serverUri: bobServerUri, - dbPath: bobDbPath - }, serverConfig) - ) - - before(() => { - return Promise.all([ - startServer(bobPod, 5001) - ]).then(() => { - bob = supertest(bobServerUri) - }) - }) - - after(() => { - bobPod.close() - cleanDir(bobRootPath) - }) - - describe('GET Payment Pointer document', () => { - it.skip('should redirect to example.com', (done) => { - bob.get('/.well-known/pay') - .expect('location', 'https://bob.com/.well-known/pay') - .expect(302, done) - }) - }) - }) - - describe('Payment Pointer Charlie', () => { - let charlie - const charlieServerUri = 'https://localhost:5002' - const charlieDbPath = path.join(__dirname, - '../resources/accounts-scenario/charlie/db') - const charlieRootPath = path.join(__dirname, '../resources/accounts-scenario/charlie') - const charliePod = Solid.createServer( - Object.assign({ - root: charlieRootPath, - serverUri: charlieServerUri, - dbPath: charlieDbPath - }, serverConfig) - ) - - before(() => { - return Promise.all([ - startServer(charliePod, 5002) - ]).then(() => { - charlie = supertest(charlieServerUri) - }) - }) - - after(() => { - charliePod.close() - cleanDir(charlieRootPath) - }) - - describe('GET Payment Pointer document', () => { - it('should redirect to example.com/charlie', (done) => { - charlie.get('/.well-known/pay') - .expect('location', 'https://service.com/charlie') - .expect(302, done) - }) - }) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import supertest from 'supertest' +import chai from 'chai' + +// Import utility functions from the ESM utils +import { cleanDir } from '../utils.js' +import * as Solid from '../../index.js' + +const { expect } = chai + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +describe('API', () => { + const configPath = path.join(__dirname, '../resources/config') + + const serverConfig = { + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + auth: 'oidc', + dataBrowser: false, + webid: true, + multiuser: false, + configPath + } + + function startServer (pod, port) { + return new Promise((resolve) => { + pod.listen(port, () => { resolve() }) + }) + } + + describe('Payment Pointer Alice', () => { + let alice + const aliceServerUri = 'https://localhost:5000' + const aliceDbPath = path.join(__dirname, + '../resources/accounts-scenario/alice/db') + const aliceRootPath = path.join(__dirname, '../resources/accounts-scenario/alice') + + const alicePod = Solid.createServer( + Object.assign({ + root: aliceRootPath, + serverUri: aliceServerUri, + dbPath: aliceDbPath + }, serverConfig) + ) + + before(() => { + return Promise.all([ + startServer(alicePod, 5000) + ]).then(() => { + alice = supertest(aliceServerUri) + }) + }) + + after(() => { + alicePod.close() + cleanDir(aliceRootPath) + }) + + describe('GET Payment Pointer document', () => { + it('should show instructions to add a triple', (done) => { + alice.get('/.well-known/pay') + .expect(200) + .expect('content-type', /application\/json/) + .end(function (err, req) { + if (err) { + done(err) + } else { + expect(req.body).deep.equal({ + fail: 'Add triple', + subject: '', + predicate: '', + object: '$alice.example' + }) + done() + } + }) + }) + }) + }) + + describe('Payment Pointer Bob', () => { + let bob + const bobServerUri = 'https://localhost:5001' + const bobDbPath = path.join(__dirname, + '../resources/accounts-scenario/bob/db') + const bobRootPath = path.join(__dirname, '../resources/accounts-scenario/bob') + const bobPod = Solid.createServer( + Object.assign({ + root: bobRootPath, + serverUri: bobServerUri, + dbPath: bobDbPath + }, serverConfig) + ) + + before(() => { + return Promise.all([ + startServer(bobPod, 5001) + ]).then(() => { + bob = supertest(bobServerUri) + }) + }) + + after(() => { + bobPod.close() + cleanDir(bobRootPath) + }) + + describe('GET Payment Pointer document', () => { + it.skip('should redirect to example.com', (done) => { + bob.get('/.well-known/pay') + .expect('location', 'https://bob.com/.well-known/pay') + .expect(302, done) + }) + }) + }) + + describe('Payment Pointer Charlie', () => { + let charlie + const charlieServerUri = 'https://localhost:5002' + const charlieDbPath = path.join(__dirname, + '../resources/accounts-scenario/charlie/db') + const charlieRootPath = path.join(__dirname, '../resources/accounts-scenario/charlie') + const charliePod = Solid.createServer( + Object.assign({ + root: charlieRootPath, + serverUri: charlieServerUri, + dbPath: charlieDbPath + }, serverConfig) + ) + + before(() => { + return Promise.all([ + startServer(charliePod, 5002) + ]).then(() => { + charlie = supertest(charlieServerUri) + }) + }) + + after(() => { + charliePod.close() + cleanDir(charlieRootPath) + }) + + describe('GET Payment Pointer document', () => { + it('should redirect to example.com/charlie', (done) => { + charlie.get('/.well-known/pay') + .expect('location', 'https://service.com/charlie') + .expect(302, done) + }) + }) + }) +}) diff --git a/test/integration/prep-test.mjs b/test/integration/prep-test.js similarity index 97% rename from test/integration/prep-test.mjs rename to test/integration/prep-test.js index f09077457..4ce064120 100644 --- a/test/integration/prep-test.mjs +++ b/test/integration/prep-test.js @@ -1,314 +1,314 @@ -import { fileURLToPath } from 'url' -import fs from 'fs' -import path from 'path' -import { validate as uuidValidate } from 'uuid' -import { expect } from 'chai' -import { parseDictionary } from 'structured-headers' -import prepFetch from 'prep-fetch' -import { createServer } from '../utils.mjs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const dateTimeRegex = /^-?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|(?:\+|-)\d{2}:\d{2})$/ - -const samplePath = path.join(__dirname, '../resources', 'sampleContainer') -const sampleFile = fs.readFileSync(path.join(samplePath, 'example1.ttl')) - -describe('Per Resource Events Protocol', function () { - let server - - before((done) => { - server = createServer({ - live: true, - dataBrowserPath: 'default', - root: path.join(__dirname, '../resources'), - auth: 'oidc', - webid: false, - prep: true - }) - server.listen(8445, done) - }) - - after(() => { - if (fs.existsSync(path.join(samplePath, 'example-post'))) { - fs.rmSync(path.join(samplePath, 'example-post'), { recursive: true, force: true }) - } - server.close() - }) - - it('should set `Accept-Events` header on a GET response with "prep"', - async function () { - const response = await fetch('http://localhost:8445/sampleContainer/example1.ttl') - expect(response.headers.get('Accept-Events')).to.match(/^"prep"/) - expect(response.status).to.equal(200) - } - ) - - it('should send an ordinary response, if `Accept-Events` header is not specified', - async function () { - const response = await fetch('http://localhost:8445/sampleContainer/example1.ttl') - expect(response.headers.get('Content-Type')).to.match(/text\/turtle/) - expect(response.headers.has('Events')).to.equal(false) - expect(response.status).to.equal(200) - }) - - describe('with prep response on container', async function () { - let response - let prepResponse - const controller = new AbortController() - const { signal } = controller - - it('should set headers correctly', async function () { - response = await fetch('http://localhost:8445/sampleContainer/', { - headers: { - 'Accept-Events': '"prep";accept=application/ld+json', - Accept: 'text/turtle' - }, - signal - }) - expect(response.status).to.equal(200) - expect(response.headers.get('Vary')).to.match(/Accept-Events/) - const eventsHeader = parseDictionary(response.headers.get('Events')) - expect(eventsHeader.get('protocol')?.[0]).to.equal('prep') - expect(eventsHeader.get('status')?.[0]).to.equal(200) - expect(eventsHeader.get('expires')?.[0]).to.be.a('string') - expect(response.headers.get('Content-Type')).to.match(/^multipart\/mixed/) - }) - - it('should send a representation as the first part, matching the content size on disk', - async function () { - prepResponse = prepFetch(response) - const representation = await prepResponse.getRepresentation() - expect(representation.headers.get('Content-Type')).to.match(/text\/turtle/) - await representation.text() - }) - - describe('should send notifications in the second part', async function () { - let notifications - let notificationsIterator - - it('when a contained resource is created', async function () { - notifications = await prepResponse.getNotifications() - notificationsIterator = notifications.notifications() - await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { - method: 'PUT', - headers: { - 'Content-Type': 'text/turtle' - }, - body: sampleFile - }) - const { value } = await notificationsIterator.next() - expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) - const notification = await value.json() - expect(notification.published).to.match(dateTimeRegex) - expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) - expect(notification.type).to.equal('Add') - expect(notification.target).to.match(/sampleContainer\/$/) - expect(notification.object).to.match(/sampleContainer\/example-prep\.ttl$/) - expect(uuidValidate(notification.id.substring(9))).to.equal(true) - expect(notification.state).to.match(/\w{6}/) - }) - - it('when contained resource is modified', async function () { - await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { - method: 'PATCH', - headers: { - 'Content-Type': 'text/n3' - }, - body: `@prefix solid: . -<> a solid:InsertDeletePatch; -solid:inserts { . }.` - }) - const { value } = await notificationsIterator.next() - expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) - const notification = await value.json() - expect(notification.published).to.match(dateTimeRegex) - expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) - expect(notification.type).to.equal('Update') - expect(notification.object).to.match(/sampleContainer\/$/) - expect(uuidValidate(notification.id.substring(9))).to.equal(true) - expect(notification.state).to.match(/\w{6}/) - }) - - it('when contained resource is deleted', - async function () { - await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { - method: 'DELETE' - }) - const { value } = await notificationsIterator.next() - expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) - const notification = await value.json() - expect(notification.published).to.match(dateTimeRegex) - expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) - expect(notification.type).to.equal('Remove') - expect(notification.origin).to.match(/sampleContainer\/$/) - expect(notification.object).to.match(/sampleContainer\/.*example-prep.ttl$/) - expect(uuidValidate(notification.id.substring(9))).to.equal(true) - expect(notification.state).to.match(/\w{6}/) - }) - - it('when a contained container is created', async function () { - await fetch('http://localhost:8445/sampleContainer/example-prep/', { - method: 'PUT', - headers: { - 'Content-Type': 'text/turtle' - } - }) - const { value } = await notificationsIterator.next() - expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) - const notification = await value.json() - expect(notification.published).to.match(dateTimeRegex) - expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) - expect(notification.type).to.equal('Add') - expect(notification.target).to.match(/sampleContainer\/$/) - expect(notification.object).to.match(/sampleContainer\/example-prep\/$/) - expect(uuidValidate(notification.id.substring(9))).to.equal(true) - expect(notification.state).to.match(/\w{6}/) - }) - - it('when a contained container is deleted', async function () { - await fetch('http://localhost:8445/sampleContainer/example-prep/', { - method: 'DELETE' - }) - const { value } = await notificationsIterator.next() - expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) - const notification = await value.json() - expect(notification.published).to.match(dateTimeRegex) - expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) - expect(notification.type).to.equal('Remove') - expect(notification.origin).to.match(/sampleContainer\/$/) - expect(notification.object).to.match(/sampleContainer\/example-prep\/$/) - expect(uuidValidate(notification.id.substring(9))).to.equal(true) - expect(notification.state).to.match(/\w{6}/) - }) - - it('when a container is created by POST', - async function () { - await fetch('http://localhost:8445/sampleContainer/', { - method: 'POST', - headers: { - slug: 'example-post', - link: '; rel="type"', - 'content-type': 'text/turtle' - } - }) - const { value } = await notificationsIterator.next() - expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) - const notification = await value.json() - expect(notification.published).to.match(dateTimeRegex) - expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) - expect(notification.type).to.equal('Add') - expect(notification.target).to.match(/sampleContainer\/$/) - expect(notification.object).to.match(/sampleContainer\/.*example-post\/$/) - expect(uuidValidate(notification.id.substring(9))).to.equal(true) - expect(notification.state).to.match(/\w{6}/) - }) - - it('when resource is created by POST', - async function () { - await fetch('http://localhost:8445/sampleContainer/', { - method: 'POST', - headers: { - slug: 'example-prep.ttl', - 'content-type': 'text/turtle' - }, - body: sampleFile - }) - const { value } = await notificationsIterator.next() - expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) - const notification = await value.json() - expect(notification.published).to.match(dateTimeRegex) - expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) - expect(notification.type).to.equal('Add') - expect(notification.target).to.match(/sampleContainer\/$/) - expect(notification.object).to.match(/sampleContainer\/.*example-prep.ttl$/) - expect(uuidValidate(notification.id.substring(9))).to.equal(true) - expect(notification.state).to.match(/\w{6}/) - controller.abort() - }) - }) - }) - - describe('with prep response on RDF resource', async function () { - let response - let prepResponse - - it('should set headers correctly', async function () { - response = await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { - headers: { - 'Accept-Events': '"prep";accept=application/ld+json', - Accept: 'text/n3' - } - }) - expect(response.status).to.equal(200) - expect(response.headers.get('Vary')).to.match(/Accept-Events/) - const eventsHeader = parseDictionary(response.headers.get('Events')) - expect(eventsHeader.get('protocol')?.[0]).to.equal('prep') - expect(eventsHeader.get('status')?.[0]).to.equal(200) - expect(eventsHeader.get('expires')?.[0]).to.be.a('string') - expect(response.headers.get('Content-Type')).to.match(/^multipart\/mixed/) - }) - - it('should send a representation as the first part, matching the content size on disk', - async function () { - prepResponse = prepFetch(response) - const representation = await prepResponse.getRepresentation() - expect(representation.headers.get('Content-Type')).to.match(/text\/n3/) - const blob = await representation.blob() - expect(function (done) { - const size = fs.statSync(path.join(__dirname, - '../resources/sampleContainer/example-prep.ttl')).size - if (blob.size !== size) { - return done(new Error('files are not of the same size')) - } - }) - }) - - describe('should send notifications in the second part', async function () { - let notifications - let notificationsIterator - - it('when modified with PATCH', async function () { - notifications = await prepResponse.getNotifications() - notificationsIterator = notifications.notifications() - await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { - method: 'PATCH', - headers: { - 'content-type': 'text/n3' - }, - body: `@prefix solid: . -<> a solid:InsertDeletePatch; -solid:inserts { . }.` - }) - const { value } = await notificationsIterator.next() - expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) - const notification = await value.json() - expect(notification.published).to.match(dateTimeRegex) - expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) - expect(notification.type).to.equal('Update') - expect(notification.object).to.match(/sampleContainer\/example-prep\.ttl$/) - expect(uuidValidate(notification.id.substring(9))).to.equal(true) - expect(notification.state).to.match(/\w{6}/) - }) - - it('when removed with DELETE, it should also close the connection', - async function () { - await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { - method: 'DELETE' - }) - const { value } = await notificationsIterator.next() - expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) - const notification = await value.json() - expect(notification.published).to.match(dateTimeRegex) - expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) - expect(notification.type).to.equal('Delete') - expect(notification.object).to.match(/sampleContainer\/example-prep\.ttl$/) - expect(uuidValidate(notification.id.substring(9))).to.equal(true) - expect(notification.state).to.match(/\w{6}/) - const { done } = await notificationsIterator.next() - expect(done).to.equal(true) - }) - }) - }) -}) +import { fileURLToPath } from 'url' +import fs from 'fs' +import path from 'path' +import { validate as uuidValidate } from 'uuid' +import { expect } from 'chai' +import { parseDictionary } from 'structured-headers' +import prepFetch from 'prep-fetch' +import { createServer } from '../utils.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const dateTimeRegex = /^-?\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(?:Z|(?:\+|-)\d{2}:\d{2})$/ + +const samplePath = path.join(__dirname, '../resources', 'sampleContainer') +const sampleFile = fs.readFileSync(path.join(samplePath, 'example1.ttl')) + +describe('Per Resource Events Protocol', function () { + let server + + before((done) => { + server = createServer({ + live: true, + dataBrowserPath: 'default', + root: path.join(__dirname, '../resources'), + auth: 'oidc', + webid: false, + prep: true + }) + server.listen(8445, done) + }) + + after(() => { + if (fs.existsSync(path.join(samplePath, 'example-post'))) { + fs.rmSync(path.join(samplePath, 'example-post'), { recursive: true, force: true }) + } + server.close() + }) + + it('should set `Accept-Events` header on a GET response with "prep"', + async function () { + const response = await fetch('http://localhost:8445/sampleContainer/example1.ttl') + expect(response.headers.get('Accept-Events')).to.match(/^"prep"/) + expect(response.status).to.equal(200) + } + ) + + it('should send an ordinary response, if `Accept-Events` header is not specified', + async function () { + const response = await fetch('http://localhost:8445/sampleContainer/example1.ttl') + expect(response.headers.get('Content-Type')).to.match(/text\/turtle/) + expect(response.headers.has('Events')).to.equal(false) + expect(response.status).to.equal(200) + }) + + describe('with prep response on container', async function () { + let response + let prepResponse + const controller = new AbortController() + const { signal } = controller + + it('should set headers correctly', async function () { + response = await fetch('http://localhost:8445/sampleContainer/', { + headers: { + 'Accept-Events': '"prep";accept=application/ld+json', + Accept: 'text/turtle' + }, + signal + }) + expect(response.status).to.equal(200) + expect(response.headers.get('Vary')).to.match(/Accept-Events/) + const eventsHeader = parseDictionary(response.headers.get('Events')) + expect(eventsHeader.get('protocol')?.[0]).to.equal('prep') + expect(eventsHeader.get('status')?.[0]).to.equal(200) + expect(eventsHeader.get('expires')?.[0]).to.be.a('string') + expect(response.headers.get('Content-Type')).to.match(/^multipart\/mixed/) + }) + + it('should send a representation as the first part, matching the content size on disk', + async function () { + prepResponse = prepFetch(response) + const representation = await prepResponse.getRepresentation() + expect(representation.headers.get('Content-Type')).to.match(/text\/turtle/) + await representation.text() + }) + + describe('should send notifications in the second part', async function () { + let notifications + let notificationsIterator + + it('when a contained resource is created', async function () { + notifications = await prepResponse.getNotifications() + notificationsIterator = notifications.notifications() + await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { + method: 'PUT', + headers: { + 'Content-Type': 'text/turtle' + }, + body: sampleFile + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification.published).to.match(dateTimeRegex) + expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) + expect(notification.type).to.equal('Add') + expect(notification.target).to.match(/sampleContainer\/$/) + expect(notification.object).to.match(/sampleContainer\/example-prep\.ttl$/) + expect(uuidValidate(notification.id.substring(9))).to.equal(true) + expect(notification.state).to.match(/\w{6}/) + }) + + it('when contained resource is modified', async function () { + await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { + method: 'PATCH', + headers: { + 'Content-Type': 'text/n3' + }, + body: `@prefix solid: . +<> a solid:InsertDeletePatch; +solid:inserts { . }.` + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification.published).to.match(dateTimeRegex) + expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) + expect(notification.type).to.equal('Update') + expect(notification.object).to.match(/sampleContainer\/$/) + expect(uuidValidate(notification.id.substring(9))).to.equal(true) + expect(notification.state).to.match(/\w{6}/) + }) + + it('when contained resource is deleted', + async function () { + await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { + method: 'DELETE' + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification.published).to.match(dateTimeRegex) + expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) + expect(notification.type).to.equal('Remove') + expect(notification.origin).to.match(/sampleContainer\/$/) + expect(notification.object).to.match(/sampleContainer\/.*example-prep.ttl$/) + expect(uuidValidate(notification.id.substring(9))).to.equal(true) + expect(notification.state).to.match(/\w{6}/) + }) + + it('when a contained container is created', async function () { + await fetch('http://localhost:8445/sampleContainer/example-prep/', { + method: 'PUT', + headers: { + 'Content-Type': 'text/turtle' + } + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification.published).to.match(dateTimeRegex) + expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) + expect(notification.type).to.equal('Add') + expect(notification.target).to.match(/sampleContainer\/$/) + expect(notification.object).to.match(/sampleContainer\/example-prep\/$/) + expect(uuidValidate(notification.id.substring(9))).to.equal(true) + expect(notification.state).to.match(/\w{6}/) + }) + + it('when a contained container is deleted', async function () { + await fetch('http://localhost:8445/sampleContainer/example-prep/', { + method: 'DELETE' + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification.published).to.match(dateTimeRegex) + expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) + expect(notification.type).to.equal('Remove') + expect(notification.origin).to.match(/sampleContainer\/$/) + expect(notification.object).to.match(/sampleContainer\/example-prep\/$/) + expect(uuidValidate(notification.id.substring(9))).to.equal(true) + expect(notification.state).to.match(/\w{6}/) + }) + + it('when a container is created by POST', + async function () { + await fetch('http://localhost:8445/sampleContainer/', { + method: 'POST', + headers: { + slug: 'example-post', + link: '; rel="type"', + 'content-type': 'text/turtle' + } + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification.published).to.match(dateTimeRegex) + expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) + expect(notification.type).to.equal('Add') + expect(notification.target).to.match(/sampleContainer\/$/) + expect(notification.object).to.match(/sampleContainer\/.*example-post\/$/) + expect(uuidValidate(notification.id.substring(9))).to.equal(true) + expect(notification.state).to.match(/\w{6}/) + }) + + it('when resource is created by POST', + async function () { + await fetch('http://localhost:8445/sampleContainer/', { + method: 'POST', + headers: { + slug: 'example-prep.ttl', + 'content-type': 'text/turtle' + }, + body: sampleFile + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification.published).to.match(dateTimeRegex) + expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) + expect(notification.type).to.equal('Add') + expect(notification.target).to.match(/sampleContainer\/$/) + expect(notification.object).to.match(/sampleContainer\/.*example-prep.ttl$/) + expect(uuidValidate(notification.id.substring(9))).to.equal(true) + expect(notification.state).to.match(/\w{6}/) + controller.abort() + }) + }) + }) + + describe('with prep response on RDF resource', async function () { + let response + let prepResponse + + it('should set headers correctly', async function () { + response = await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { + headers: { + 'Accept-Events': '"prep";accept=application/ld+json', + Accept: 'text/n3' + } + }) + expect(response.status).to.equal(200) + expect(response.headers.get('Vary')).to.match(/Accept-Events/) + const eventsHeader = parseDictionary(response.headers.get('Events')) + expect(eventsHeader.get('protocol')?.[0]).to.equal('prep') + expect(eventsHeader.get('status')?.[0]).to.equal(200) + expect(eventsHeader.get('expires')?.[0]).to.be.a('string') + expect(response.headers.get('Content-Type')).to.match(/^multipart\/mixed/) + }) + + it('should send a representation as the first part, matching the content size on disk', + async function () { + prepResponse = prepFetch(response) + const representation = await prepResponse.getRepresentation() + expect(representation.headers.get('Content-Type')).to.match(/text\/n3/) + const blob = await representation.blob() + expect(function (done) { + const size = fs.statSync(path.join(__dirname, + '../resources/sampleContainer/example-prep.ttl')).size + if (blob.size !== size) { + return done(new Error('files are not of the same size')) + } + }) + }) + + describe('should send notifications in the second part', async function () { + let notifications + let notificationsIterator + + it('when modified with PATCH', async function () { + notifications = await prepResponse.getNotifications() + notificationsIterator = notifications.notifications() + await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { + method: 'PATCH', + headers: { + 'content-type': 'text/n3' + }, + body: `@prefix solid: . +<> a solid:InsertDeletePatch; +solid:inserts { . }.` + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification.published).to.match(dateTimeRegex) + expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) + expect(notification.type).to.equal('Update') + expect(notification.object).to.match(/sampleContainer\/example-prep\.ttl$/) + expect(uuidValidate(notification.id.substring(9))).to.equal(true) + expect(notification.state).to.match(/\w{6}/) + }) + + it('when removed with DELETE, it should also close the connection', + async function () { + await fetch('http://localhost:8445/sampleContainer/example-prep.ttl', { + method: 'DELETE' + }) + const { value } = await notificationsIterator.next() + expect(value.headers.get('content-type')).to.match(/application\/ld\+json/) + const notification = await value.json() + expect(notification.published).to.match(dateTimeRegex) + expect(isNaN((new Date(notification.published)).valueOf())).to.equal(false) + expect(notification.type).to.equal('Delete') + expect(notification.object).to.match(/sampleContainer\/example-prep\.ttl$/) + expect(uuidValidate(notification.id.substring(9))).to.equal(true) + expect(notification.state).to.match(/\w{6}/) + const { done } = await notificationsIterator.next() + expect(done).to.equal(true) + }) + }) + }) +}) diff --git a/test/integration/quota-test.mjs b/test/integration/quota-test.js similarity index 92% rename from test/integration/quota-test.mjs rename to test/integration/quota-test.js index 69e058086..9e41acdd4 100644 --- a/test/integration/quota-test.mjs +++ b/test/integration/quota-test.js @@ -1,50 +1,50 @@ -import path from 'path' -import chai from 'chai' - -// Import utility functions from the ESM utils -import { read } from '../utils.mjs' -import { getQuota, overQuota } from '../../lib/utils.mjs' - -const { expect } = chai - -const root = 'accounts-acl/config/templates/new-account/' - -describe('Get Quota', function () { - const prefs = read(path.join(root, 'settings/serverSide.ttl')) - it('from file to check that it is readable and has predicate', function () { - expect(prefs).to.be.a('string') - expect(prefs).to.match(/storageQuota/) - }) - it('and check it', async function () { - const quota = await getQuota(path.join('test/resources/', root), 'https://localhost') - console.log('Quota is', quota) - expect(quota).to.equal(2000) - }) - it('with wrong size', async function () { - const quota = await getQuota(path.join('test/resources/', root), 'https://localhost') - expect(quota).to.not.equal(3000) - }) - it('with non-existant file', async function () { - const quota = await getQuota(path.join('nowhere/', root), 'https://localhost') - expect(quota).to.equal(Infinity) - }) - it('when the predicate is not present', async function () { - const quota = await getQuota('test/resources/accounts-acl/quota', 'https://localhost') - expect(quota).to.equal(Infinity) - }) -}) - -describe('Check if over Quota', function () { - it('when it is above', async function () { - const quota = await overQuota(path.join('test/resources/', root), 'https://localhost') - expect(quota).to.be.true - }) - it('with non-existant file', async function () { - const quota = await overQuota(path.join('nowhere/', root), 'https://localhost') - expect(quota).to.be.false - }) - it('when the predicate is not present', async function () { - const quota = await overQuota('test/resources/accounts-acl/quota', 'https://localhost') - expect(quota).to.be.false - }) -}) +import path from 'path' +import chai from 'chai' + +// Import utility functions from the ESM utils +import { read } from '../utils.js' +import { getQuota, overQuota } from '../../lib/utils.js' + +const { expect } = chai + +const root = 'accounts-acl/config/templates/new-account/' + +describe('Get Quota', function () { + const prefs = read(path.join(root, 'settings/serverSide.ttl')) + it('from file to check that it is readable and has predicate', function () { + expect(prefs).to.be.a('string') + expect(prefs).to.match(/storageQuota/) + }) + it('and check it', async function () { + const quota = await getQuota(path.join('test/resources/', root), 'https://localhost') + console.log('Quota is', quota) + expect(quota).to.equal(2000) + }) + it('with wrong size', async function () { + const quota = await getQuota(path.join('test/resources/', root), 'https://localhost') + expect(quota).to.not.equal(3000) + }) + it('with non-existant file', async function () { + const quota = await getQuota(path.join('nowhere/', root), 'https://localhost') + expect(quota).to.equal(Infinity) + }) + it('when the predicate is not present', async function () { + const quota = await getQuota('test/resources/accounts-acl/quota', 'https://localhost') + expect(quota).to.equal(Infinity) + }) +}) + +describe('Check if over Quota', function () { + it('when it is above', async function () { + const quota = await overQuota(path.join('test/resources/', root), 'https://localhost') + expect(quota).to.be.true + }) + it('with non-existant file', async function () { + const quota = await overQuota(path.join('nowhere/', root), 'https://localhost') + expect(quota).to.be.false + }) + it('when the predicate is not present', async function () { + const quota = await overQuota('test/resources/accounts-acl/quota', 'https://localhost') + expect(quota).to.be.false + }) +}) diff --git a/test/integration/special-root-acl-handling-test.mjs b/test/integration/special-root-acl-handling-test.js similarity index 93% rename from test/integration/special-root-acl-handling-test.mjs rename to test/integration/special-root-acl-handling-test.js index 1532e8742..c4085e7f4 100644 --- a/test/integration/special-root-acl-handling-test.mjs +++ b/test/integration/special-root-acl-handling-test.js @@ -1,68 +1,68 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import { assert } from 'chai' -import { httpRequest as request, checkDnsSettings, cleanDir } from '../utils.mjs' -import ldnode from '../../index.mjs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const port = 7777 -const serverUri = `https://localhost:${port}` -const root = path.join(__dirname, '../resources/accounts-acl') -const dbPath = path.join(root, 'db') -const configPath = path.join(root, 'config') - -function createOptions (path = '') { - return { - url: `https://nicola.localhost:${port}${path}` - } -} - -describe('Special handling: Root ACL does not give READ access to root', () => { - let ldp, ldpHttpsServer - - before(checkDnsSettings) - - before(done => { - ldp = ldnode.createServer({ - root, - serverUri, - dbPath, - port, - configPath, - sslKey: path.join(__dirname, '../keys/key.pem'), - sslCert: path.join(__dirname, '../keys/cert.pem'), - webid: true, - multiuser: true, - auth: 'oidc', - strictOrigin: true, - host: { serverUri } - }) - ldpHttpsServer = ldp.listen(port, done) - }) - - after(() => { - if (ldpHttpsServer) ldpHttpsServer.close() - cleanDir(root) - }) - - describe('should still grant READ access to everyone because of index.html.acl', () => { - it('for root with /', function (done) { - const options = createOptions('/') - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - it('for root without /', function (done) { - const options = createOptions() - request.get(options, function (error, response, body) { - assert.equal(error, null) - assert.equal(response.statusCode, 200) - done() - }) - }) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import { assert } from 'chai' +import { httpRequest as request, checkDnsSettings, cleanDir } from '../utils.js' +import ldnode from '../../index.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const port = 7777 +const serverUri = `https://localhost:${port}` +const root = path.join(__dirname, '../resources/accounts-acl') +const dbPath = path.join(root, 'db') +const configPath = path.join(root, 'config') + +function createOptions (path = '') { + return { + url: `https://nicola.localhost:${port}${path}` + } +} + +describe('Special handling: Root ACL does not give READ access to root', () => { + let ldp, ldpHttpsServer + + before(checkDnsSettings) + + before(done => { + ldp = ldnode.createServer({ + root, + serverUri, + dbPath, + port, + configPath, + sslKey: path.join(__dirname, '../keys/key.pem'), + sslCert: path.join(__dirname, '../keys/cert.pem'), + webid: true, + multiuser: true, + auth: 'oidc', + strictOrigin: true, + host: { serverUri } + }) + ldpHttpsServer = ldp.listen(port, done) + }) + + after(() => { + if (ldpHttpsServer) ldpHttpsServer.close() + cleanDir(root) + }) + + describe('should still grant READ access to everyone because of index.html.acl', () => { + it('for root with /', function (done) { + const options = createOptions('/') + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + it('for root without /', function (done) { + const options = createOptions() + request.get(options, function (error, response, body) { + assert.equal(error, null) + assert.equal(response.statusCode, 200) + done() + }) + }) + }) +}) diff --git a/test/integration/validate-tts-test.mjs b/test/integration/validate-tts-test.js similarity index 93% rename from test/integration/validate-tts-test.mjs rename to test/integration/validate-tts-test.js index 595303db8..b3b474a91 100644 --- a/test/integration/validate-tts-test.mjs +++ b/test/integration/validate-tts-test.js @@ -1,57 +1,57 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import fs from 'fs' - -// Import utility functions from the ESM utils -import { setupSupertestServer } from '../utils.mjs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const server = setupSupertestServer({ - live: true, - dataBrowserPath: 'default', - root: path.join(__dirname, '../resources'), - auth: 'oidc', - webid: false -}) - -const invalidTurtleBody = fs.readFileSync(path.join(__dirname, '../resources/invalid1.ttl'), { - encoding: 'utf8' -}) - -describe('HTTP requests with invalid Turtle syntax', () => { - describe('PUT API', () => { - it('is allowed with invalid TTL files in general', (done) => { - server.put('/invalid1.ttl') - .send(invalidTurtleBody) - .set('content-type', 'text/turtle') - .expect(204, done) - }) - - it('is not allowed with invalid ACL files', (done) => { - server.put('/invalid1.ttl.acl') - .send(invalidTurtleBody) - .set('content-type', 'text/turtle') - .expect(400, done) - }) - }) - - describe('PATCH API', () => { - it('does not support patching of TTL files', (done) => { - server.patch('/patch-1-initial.ttl') - .send(invalidTurtleBody) - .set('content-type', 'text/turtle') - .expect(415, done) - }) - }) - - describe('POST API (multipart)', () => { - it('does not validate files that are posted', (done) => { - server.post('/') - .attach('invalid1', path.join(__dirname, '../resources/invalid1.ttl')) - .attach('invalid2', path.join(__dirname, '../resources/invalid2.ttl')) - .expect(200, done) - }) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import fs from 'fs' + +// Import utility functions from the ESM utils +import { setupSupertestServer } from '../utils.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const server = setupSupertestServer({ + live: true, + dataBrowserPath: 'default', + root: path.join(__dirname, '../resources'), + auth: 'oidc', + webid: false +}) + +const invalidTurtleBody = fs.readFileSync(path.join(__dirname, '../resources/invalid1.ttl'), { + encoding: 'utf8' +}) + +describe('HTTP requests with invalid Turtle syntax', () => { + describe('PUT API', () => { + it('is allowed with invalid TTL files in general', (done) => { + server.put('/invalid1.ttl') + .send(invalidTurtleBody) + .set('content-type', 'text/turtle') + .expect(204, done) + }) + + it('is not allowed with invalid ACL files', (done) => { + server.put('/invalid1.ttl.acl') + .send(invalidTurtleBody) + .set('content-type', 'text/turtle') + .expect(400, done) + }) + }) + + describe('PATCH API', () => { + it('does not support patching of TTL files', (done) => { + server.patch('/patch-1-initial.ttl') + .send(invalidTurtleBody) + .set('content-type', 'text/turtle') + .expect(415, done) + }) + }) + + describe('POST API (multipart)', () => { + it('does not validate files that are posted', (done) => { + server.post('/') + .attach('invalid1', path.join(__dirname, '../resources/invalid1.ttl')) + .attach('invalid2', path.join(__dirname, '../resources/invalid2.ttl')) + .expect(200, done) + }) + }) +}) diff --git a/test/integration/www-account-creation-oidc-test.mjs b/test/integration/www-account-creation-oidc-test.js similarity index 96% rename from test/integration/www-account-creation-oidc-test.mjs rename to test/integration/www-account-creation-oidc-test.js index 9ef4c61f5..f9dacd6fc 100644 --- a/test/integration/www-account-creation-oidc-test.mjs +++ b/test/integration/www-account-creation-oidc-test.js @@ -1,310 +1,310 @@ -import supertest from 'supertest' -import rdf from 'rdflib' -import ldnode from '../../index.mjs' -import path from 'path' -import { fileURLToPath } from 'url' -import fs from 'fs-extra' -import { rm, read, checkDnsSettings, cleanDir } from '../utils/index.mjs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const $rdf = rdf - -// FIXME: #1502 -describe('AccountManager (OIDC account creation tests)', function () { - const port = 7457 - const serverUri = `https://localhost:${port}` - const host = `localhost:${port}` - const root = path.normalize(path.join(__dirname, '../resources/accounts/')) - const configPath = path.normalize(path.join(__dirname, '../resources/config')) - const dbPath = path.normalize(path.join(__dirname, '../resources/accounts/db')) - - let ldpHttpsServer - - const ldp = ldnode.createServer({ - root, - configPath, - sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), - sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), - auth: 'oidc', - webid: true, - multiuser: true, - strictOrigin: true, - dbPath, - serverUri, - enforceToc: true - }) - - before(checkDnsSettings) - - before(function (done) { - ldpHttpsServer = ldp.listen(port, done) - }) - - after(function () { - if (ldpHttpsServer) ldpHttpsServer.close() - fs.removeSync(path.join(dbPath, 'oidc/users/users')) - cleanDir(path.join(root, 'localhost')) - }) - - const server = supertest(serverUri) - - it('should expect a 404 on GET /accounts', function (done) { - server.get('/api/accounts') - .expect(404, done) - }) - - describe('accessing accounts', function () { - it('should be able to access public file of an account', function (done) { - const subdomain = supertest('https://tim.' + host) - subdomain.get('/hello.html') - .expect(200, done) - }) - it('should get 404 if root does not exist', function (done) { - const subdomain = supertest('https://nicola.' + host) - subdomain.get('/') - .set('Accept', 'text/turtle') - .set('Origin', 'http://example.com') - .expect(404) - .expect('Access-Control-Allow-Origin', 'http://example.com') - .expect('Access-Control-Allow-Credentials', 'true') - .end(function (err, res) { - done(err) - }) - }) - }) - - describe('creating an account with POST', function () { - beforeEach(function () { - rm('accounts/nicola.localhost') - }) - - after(function () { - rm('accounts/nicola.localhost') - }) - - it('should not create WebID if no username is given', (done) => { - const subdomain = supertest('https://' + host) - subdomain.post('/api/accounts/new') - .send('username=&password=12345') - .expect(400, done) - }) - - it('should not create WebID if no password is given', (done) => { - const subdomain = supertest('https://' + host) - subdomain.post('/api/accounts/new') - .send('username=nicola&password=') - .expect(400, done) - }) - - it('should not create a WebID if it already exists', function (done) { - const subdomain = supertest('https://' + host) - subdomain.post('/api/accounts/new') - .send('username=nicola&password=12345&acceptToc=true') - .expect(302) - .end((err, res) => { - if (err) { - return done(err) - } - subdomain.post('/api/accounts/new') - .send('username=nicola&password=12345&acceptToc=true') - .expect(400) - .end((err) => { - done(err) - }) - }) - }) - - it('should not create WebID if T&C is not accepted', (done) => { - const subdomain = supertest('https://' + host) - subdomain.post('/api/accounts/new') - .send('username=nicola&password=12345&acceptToc=') - .expect(400, done) - }) - - it('should create the default folders', function (done) { - const subdomain = supertest('https://' + host) - subdomain.post('/api/accounts/new') - .send('username=nicola&password=12345&acceptToc=true') - .expect(302) - .end(function (err) { - if (err) { - return done(err) - } - const domain = host.split(':')[0] - const card = read(path.normalize(path.join('accounts/nicola.' + domain, - 'profile/card$.ttl'))) - const cardAcl = read(path.normalize(path.join('accounts/nicola.' + domain, - 'profile/.acl'))) - const prefs = read(path.normalize(path.join('accounts/nicola.' + domain, - 'settings/prefs.ttl'))) - const inboxAcl = read(path.normalize(path.join('accounts/nicola.' + domain, - 'inbox/.acl'))) - const rootMeta = read(path.normalize(path.join('accounts/nicola.' + domain, '.meta'))) - const rootMetaAcl = read(path.normalize(path.join('accounts/nicola.' + domain, - '.meta.acl'))) - - if (domain && card && cardAcl && prefs && inboxAcl && rootMeta && - rootMetaAcl) { - done() - } else { - done(new Error('failed to create default files')) - } - }) - }).timeout(20000) - - it('should link WebID to the root account', function (done) { - const domain = supertest('https://' + host) - domain.post('/api/accounts/new') - .send('username=nicola&password=12345&acceptToc=true') - .expect(302) - .end(function (err) { - if (err) { - return done(err) - } - const subdomain = supertest('https://nicola.' + host) - subdomain.get('/.meta') - .expect(200) - .end(function (err, data) { - if (err) { - return done(err) - } - const graph = $rdf.graph() - $rdf.parse( - data.text, - graph, - 'https://nicola.' + host + '/.meta', - 'text/turtle') - const statements = graph.statementsMatching( - undefined, - $rdf.sym('http://www.w3.org/ns/solid/terms#account'), - undefined) - if (statements.length === 1) { - done() - } else { - done(new Error('missing link to WebID of account')) - } - }) - }) - }).timeout(20000) - - describe('after setting up account', () => { - beforeEach(done => { - const subdomain = supertest('https://' + host) - subdomain.post('/api/accounts/new') - .send('username=nicola&password=12345&acceptToc=true') - .end(done) - }) - - it('should create a private settings container', function (done) { - const subdomain = supertest('https://nicola.' + host) - subdomain.head('/settings/') - .expect(401) - .end(function (err) { - done(err) - }) - }) - - it('should create a private prefs file in the settings container', function (done) { - const subdomain = supertest('https://nicola.' + host) - subdomain.head('/inbox/prefs.ttl') - .expect(401) - .end(function (err) { - done(err) - }) - }) - - it('should create a private inbox container', function (done) { - const subdomain = supertest('https://nicola.' + host) - subdomain.head('/inbox/') - .expect(401) - .end(function (err) { - done(err) - }) - }) - }) - }) -}) - -// FIXME: #1502 -describe('Single User signup page', () => { - const serverUri = 'https://localhost:7457' - const port = 7457 - let ldpHttpsServer - rm('resources/accounts/single-user/') - const rootDir = path.normalize(path.join(__dirname, '../resources/accounts/single-user/')) - const configPath = path.normalize(path.join(__dirname, '../resources/config')) - const ldp = ldnode.createServer({ - port, - root: rootDir, - configPath, - sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), - sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), - webid: true, - multiuser: false, - strictOrigin: true - }) - const server = supertest(serverUri) - - before(function (done) { - ldpHttpsServer = ldp.listen(port, () => server.post('/api/accounts/new') - .send('username=foo&password=12345&acceptToc=true') - .end(done)) - }) - - after(function () { - if (ldpHttpsServer) ldpHttpsServer.close() - fs.removeSync(rootDir) - }) - - it('should return a 406 not acceptable without accept text/html', done => { - server.get('/') - .set('accept', 'text/plain') - .expect(406) - .end(done) - }) -}) - -// FIXME: #1502 -describe('Signup page where Terms & Conditions are not being enforced', () => { - const port = 3457 - const host = `localhost:${port}` - const root = path.normalize(path.join(__dirname, '../resources/accounts/')) - const configPath = path.normalize(path.join(__dirname, '../resources/config')) - const dbPath = path.normalize(path.join(__dirname, '../resources/accounts/db')) - const ldp = ldnode.createServer({ - port, - root, - configPath, - sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), - sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), - auth: 'oidc', - webid: true, - multiuser: true, - strictOrigin: true, - enforceToc: false - }) - let ldpHttpsServer - - before(function (done) { - ldpHttpsServer = ldp.listen(port, done) - }) - - after(function () { - if (ldpHttpsServer) ldpHttpsServer.close() - fs.removeSync(path.join(dbPath, 'oidc/users/users')) - cleanDir(path.join(root, 'localhost')) - rm('accounts/nicola.localhost') - }) - - beforeEach(function () { - rm('accounts/nicola.localhost') - }) - - it('should not enforce T&C upon creating account', function (done) { - const subdomain = supertest('https://' + host) - subdomain.post('/api/accounts/new') - .send('username=nicola&password=12345') - .expect(302, done) - }) -}) +import supertest from 'supertest' +import rdf from 'rdflib' +import ldnode from '../../index.js' +import path from 'path' +import { fileURLToPath } from 'url' +import fs from 'fs-extra' +import { rm, read, checkDnsSettings, cleanDir } from '../utils/index.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const $rdf = rdf + +// FIXME: #1502 +describe('AccountManager (OIDC account creation tests)', function () { + const port = 7457 + const serverUri = `https://localhost:${port}` + const host = `localhost:${port}` + const root = path.normalize(path.join(__dirname, '../resources/accounts/')) + const configPath = path.normalize(path.join(__dirname, '../resources/config')) + const dbPath = path.normalize(path.join(__dirname, '../resources/accounts/db')) + + let ldpHttpsServer + + const ldp = ldnode.createServer({ + root, + configPath, + sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), + sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), + auth: 'oidc', + webid: true, + multiuser: true, + strictOrigin: true, + dbPath, + serverUri, + enforceToc: true + }) + + before(checkDnsSettings) + + before(function (done) { + ldpHttpsServer = ldp.listen(port, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + fs.removeSync(path.join(dbPath, 'oidc/users/users')) + cleanDir(path.join(root, 'localhost')) + }) + + const server = supertest(serverUri) + + it('should expect a 404 on GET /accounts', function (done) { + server.get('/api/accounts') + .expect(404, done) + }) + + describe('accessing accounts', function () { + it('should be able to access public file of an account', function (done) { + const subdomain = supertest('https://tim.' + host) + subdomain.get('/hello.html') + .expect(200, done) + }) + it('should get 404 if root does not exist', function (done) { + const subdomain = supertest('https://nicola.' + host) + subdomain.get('/') + .set('Accept', 'text/turtle') + .set('Origin', 'http://example.com') + .expect(404) + .expect('Access-Control-Allow-Origin', 'http://example.com') + .expect('Access-Control-Allow-Credentials', 'true') + .end(function (err, res) { + done(err) + }) + }) + }) + + describe('creating an account with POST', function () { + beforeEach(function () { + rm('accounts/nicola.localhost') + }) + + after(function () { + rm('accounts/nicola.localhost') + }) + + it('should not create WebID if no username is given', (done) => { + const subdomain = supertest('https://' + host) + subdomain.post('/api/accounts/new') + .send('username=&password=12345') + .expect(400, done) + }) + + it('should not create WebID if no password is given', (done) => { + const subdomain = supertest('https://' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&password=') + .expect(400, done) + }) + + it('should not create a WebID if it already exists', function (done) { + const subdomain = supertest('https://' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&password=12345&acceptToc=true') + .expect(302) + .end((err, res) => { + if (err) { + return done(err) + } + subdomain.post('/api/accounts/new') + .send('username=nicola&password=12345&acceptToc=true') + .expect(400) + .end((err) => { + done(err) + }) + }) + }) + + it('should not create WebID if T&C is not accepted', (done) => { + const subdomain = supertest('https://' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&password=12345&acceptToc=') + .expect(400, done) + }) + + it('should create the default folders', function (done) { + const subdomain = supertest('https://' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&password=12345&acceptToc=true') + .expect(302) + .end(function (err) { + if (err) { + return done(err) + } + const domain = host.split(':')[0] + const card = read(path.normalize(path.join('accounts/nicola.' + domain, + 'profile/card$.ttl'))) + const cardAcl = read(path.normalize(path.join('accounts/nicola.' + domain, + 'profile/.acl'))) + const prefs = read(path.normalize(path.join('accounts/nicola.' + domain, + 'settings/prefs.ttl'))) + const inboxAcl = read(path.normalize(path.join('accounts/nicola.' + domain, + 'inbox/.acl'))) + const rootMeta = read(path.normalize(path.join('accounts/nicola.' + domain, '.meta'))) + const rootMetaAcl = read(path.normalize(path.join('accounts/nicola.' + domain, + '.meta.acl'))) + + if (domain && card && cardAcl && prefs && inboxAcl && rootMeta && + rootMetaAcl) { + done() + } else { + done(new Error('failed to create default files')) + } + }) + }).timeout(20000) + + it('should link WebID to the root account', function (done) { + const domain = supertest('https://' + host) + domain.post('/api/accounts/new') + .send('username=nicola&password=12345&acceptToc=true') + .expect(302) + .end(function (err) { + if (err) { + return done(err) + } + const subdomain = supertest('https://nicola.' + host) + subdomain.get('/.meta') + .expect(200) + .end(function (err, data) { + if (err) { + return done(err) + } + const graph = $rdf.graph() + $rdf.parse( + data.text, + graph, + 'https://nicola.' + host + '/.meta', + 'text/turtle') + const statements = graph.statementsMatching( + undefined, + $rdf.sym('http://www.w3.org/ns/solid/terms#account'), + undefined) + if (statements.length === 1) { + done() + } else { + done(new Error('missing link to WebID of account')) + } + }) + }) + }).timeout(20000) + + describe('after setting up account', () => { + beforeEach(done => { + const subdomain = supertest('https://' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&password=12345&acceptToc=true') + .end(done) + }) + + it('should create a private settings container', function (done) { + const subdomain = supertest('https://nicola.' + host) + subdomain.head('/settings/') + .expect(401) + .end(function (err) { + done(err) + }) + }) + + it('should create a private prefs file in the settings container', function (done) { + const subdomain = supertest('https://nicola.' + host) + subdomain.head('/inbox/prefs.ttl') + .expect(401) + .end(function (err) { + done(err) + }) + }) + + it('should create a private inbox container', function (done) { + const subdomain = supertest('https://nicola.' + host) + subdomain.head('/inbox/') + .expect(401) + .end(function (err) { + done(err) + }) + }) + }) + }) +}) + +// FIXME: #1502 +describe('Single User signup page', () => { + const serverUri = 'https://localhost:7457' + const port = 7457 + let ldpHttpsServer + rm('resources/accounts/single-user/') + const rootDir = path.normalize(path.join(__dirname, '../resources/accounts/single-user/')) + const configPath = path.normalize(path.join(__dirname, '../resources/config')) + const ldp = ldnode.createServer({ + port, + root: rootDir, + configPath, + sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), + sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), + webid: true, + multiuser: false, + strictOrigin: true + }) + const server = supertest(serverUri) + + before(function (done) { + ldpHttpsServer = ldp.listen(port, () => server.post('/api/accounts/new') + .send('username=foo&password=12345&acceptToc=true') + .end(done)) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + fs.removeSync(rootDir) + }) + + it('should return a 406 not acceptable without accept text/html', done => { + server.get('/') + .set('accept', 'text/plain') + .expect(406) + .end(done) + }) +}) + +// FIXME: #1502 +describe('Signup page where Terms & Conditions are not being enforced', () => { + const port = 3457 + const host = `localhost:${port}` + const root = path.normalize(path.join(__dirname, '../resources/accounts/')) + const configPath = path.normalize(path.join(__dirname, '../resources/config')) + const dbPath = path.normalize(path.join(__dirname, '../resources/accounts/db')) + const ldp = ldnode.createServer({ + port, + root, + configPath, + sslKey: path.normalize(path.join(__dirname, '../keys/key.pem')), + sslCert: path.normalize(path.join(__dirname, '../keys/cert.pem')), + auth: 'oidc', + webid: true, + multiuser: true, + strictOrigin: true, + enforceToc: false + }) + let ldpHttpsServer + + before(function (done) { + ldpHttpsServer = ldp.listen(port, done) + }) + + after(function () { + if (ldpHttpsServer) ldpHttpsServer.close() + fs.removeSync(path.join(dbPath, 'oidc/users/users')) + cleanDir(path.join(root, 'localhost')) + rm('accounts/nicola.localhost') + }) + + beforeEach(function () { + rm('accounts/nicola.localhost') + }) + + it('should not enforce T&C upon creating account', function (done) { + const subdomain = supertest('https://' + host) + subdomain.post('/api/accounts/new') + .send('username=nicola&password=12345') + .expect(302, done) + }) +}) diff --git a/test/resources/accounts-scenario/alice/db/oidc/op/clients/_key_d6b702e839cc4d8a28a28bb240f6b477.json b/test/resources/accounts-scenario/alice/db/oidc/op/clients/_key_d6b702e839cc4d8a28a28bb240f6b477.json new file mode 100644 index 000000000..c65d726e9 --- /dev/null +++ b/test/resources/accounts-scenario/alice/db/oidc/op/clients/_key_d6b702e839cc4d8a28a28bb240f6b477.json @@ -0,0 +1 @@ +{"redirect_uris":["https://localhost:7000/api/oidc/rp/https%3A%2F%2Flocalhost%3A7000"],"client_id":"d6b702e839cc4d8a28a28bb240f6b477","client_secret":"c20ce8d163c2f889dbe9737844ec4373","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7000","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7000/goodbye"]} \ No newline at end of file diff --git a/test/resources/accounts-scenario/alice/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json b/test/resources/accounts-scenario/alice/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json new file mode 100644 index 000000000..ef342b837 --- /dev/null +++ b/test/resources/accounts-scenario/alice/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7000.json @@ -0,0 +1 @@ +{"provider":{"url":"https://localhost:7000","configuration":{"issuer":"https://localhost:7000","jwks_uri":"https://localhost:7000/jwks","scopes_supported":["openid","offline_access","webid"],"response_types_supported":["code","code token","code id_token","id_token code","id_token","id_token token","code id_token token","none"],"token_types_supported":["legacyPop","dpop"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","ES256"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256","ES256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":["sub","iss","aud","exp","iat","webid"],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7000/session","end_session_endpoint":"https://localhost:7000/logout","authorization_endpoint":"https://localhost:7000/authorize","token_endpoint":"https://localhost:7000/token","userinfo_endpoint":"https://localhost:7000/userinfo","registration_endpoint":"https://localhost:7000/register"},"jwks":{"keys":[{"kid":"3osQR_zuGDo","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"8ddFuDRT8ZZzg9t2zhehaSEpargR1RolRWG4u3IQF1C47yVG5ssSJ8A5aP4-C5exEs5QeD9Yknlmwa7woSyoe_oVRAhzsRcNHIYBLoekfBtdyA-FwHegs6DsZNNiTHBoh28z9ouxfmdWwTllTuJwm0v0IJPk-lVOs0a9KtJ_3yEvhvGGC-1v6I_q4dsDBix7AIx_tmQlM-mD8A-YZevd-c596HqbH4_rKjIwQqAK5HW8-gg4UsZWBk1yPGie3hFTdzOek02re9tmZL4CTPNggjzWMkENX9ZHazrufU6jH0FDVfjsAurR3x0W8OOyxVDAyfB0h0zlhw1wrETdzdcPRQ","e":"AQAB"},{"kid":"EdkjhBu4qXs","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"5kK1T2ygwEZVrlV7LCxtjPeS5espbyOiPmX44SB6iILHn-iYUm2O0kSWKKewDWqN6WYxHYZPI90QLmDp9tc85ubR1CcCXniliOdYizFtnuU8HduAdmh0ZYu4_9DzCN1C4YbXL48riFhPokRtClNIighFoz586BTEfMj3L65LD2T7uKXwps333PCOXLIDZY7TGZMh6s5uXvB_de2fIpnd83fCy0zO1neVaUIZJOS4M1_BGpskvAeG33gwMGjpYJmfmtENPzY4G9woEzs11zj4mPCeNH4zPVwykLKQtjlumS4J1sbpAYR4EPRiv0VKUqf2qgu0J0j4z-fRY3tNpPcfkQ","e":"AQAB"},{"kid":"D-OwM2WaUhQ","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"rE6ISe7c-Jd8_rviVIOGZHd_1gCDhe2JHSi4R179-IzwUSkDqoxN9M3ftYAFkZmzVHuh4BHpKa-k1k873nKcDek1ww8_0BX_hG4en_9Qfv-5sQ4SZDkL2-HKKuPP3GBd0wCnrdvq7Ma1Ld6QN2sg2NeZRbHbp7soi7PTVyCUXSmrkXBzfGWiVrTWj6F8JALJIPz5RzL3kIAqA9IiV0CB9eoHem27Umt-TGfLAyzIHwpB68rLj6Oq6_yY6quSIWsrxnI9J5_3CrWv4sOMUvzfV1ItbhSc-EgZw2_JTaO06-ayQS0lIeEnHaAdM4vM3n_Q-FKgiFNk09Kgjf601aD7yQ","e":"AQAB"},{"kid":"uLrNzd5NeEA","alg":"ES256","kty":"EC","crv":"P-256","key_ops":["verify"],"ext":true,"x":"6KQdgFG75Z3i5S6Cfa_LzDLDghsk2wPr1kI1AS8agUM","y":"b2xjgkYSgJYhLyHeSMzQXKgNmNjOTHfoGQTG0sbBqTc"},{"kid":"ZgwVYV_F_Gs","alg":"ES384","kty":"EC","crv":"P-384","key_ops":["verify"],"ext":true,"x":"XHAoV1E3CCWaN797bZhWMuiYgD-ycqapdvOWN15jkn-TO03X0yp-rQVpqLy59pV3","y":"5d66QpU-7e79JCwn_CaD4xhFnd3-bgjYus_2wwQc6SBoiE67TXVDBrxbB8Vj57VG"},{"kid":"ofiKdPfhUTU","alg":"ES512","kty":"EC","crv":"P-521","key_ops":["verify"],"ext":true,"x":"AA8gt5UNrh9IQZIK04_lsPfdmvh68pjiqziLkwfQozbCdVsQuVvSuLhcTW2fYJtK_Q-G4uq_VkvEtQPtAiUNDGDO","y":"AVIPsOlwUY_t26FA51dekJKdZJ61ekZz66yfHiBbz-KPYUkeiLzsotF3aEIHz4VP8zaJZmiWmQ94Z4e_5KKGdLUx"},{"kid":"jU-9gy64YDc","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"lXhf1o8JRUYm5lQ01BMP-Q--bTdIzwjZM2XKRcLlZoHCdBO3-xIGyuWu0MRxtltS-R8U_GJW9guUY2zOeP7PMqi18ruU3EkIqMwrwmoXDBmsJKH1fJgiieTzrxgFj7wxol1kJ-3oNAvidb6fU8ud4ChWTI-AJHJuD_w7_c3uBhp7LY9t0YCx2xeuUeMooP52blbgTj_LJcx5FNwnvb4-Ua2XGM8hGJGLjgfK_pwwjdSyHZMdxlfpEMNPus1OJEMqazJowJHVWKZvlGkNwjxdjRPyB6bftUpMhosFClnIFOKITJM93Zaju4NtdP3aUMy2ETGMOl72i2Q50hNmFVCXYQ","e":"AQAB"},{"kid":"cx6rJ-qCJhA","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"wCsnr_acbl9bV09rfGyKEZRtJzodlrXLBImpwNCcW383DhHnMIDHAXgwbxu6ChVSxocuFmNOaaMl8IK6J-_BmNOv18arTUbnMHB1x6LjUBkvCFT3rmB0Y1lp8Yc1z2tJeU343-lNMu_vEr3PD08SdE5breu6VuNmDLiQKYoVJSXafJoQzzh_nzCuVgeoEeSPUmNDw2_QzjXb0Oy0nL1a7JlD53MSCxi5bJ36OtQDEqwULWkwBZXTELRTeH5cJl6DDg1lOoXb3VQpdend7PbSyTL_gTZTP94_d8zzCgNUII_Euq3pfLUdJxxSzcaqAbAHQ7_pu3RQq6hub8gzbj-7rw","e":"AQAB"},{"kid":"pBNC27d8chY","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"vpRPrbYU-jMtnAf-yUO2Uo_8T_udzCBDxtRIQPEbXxWENjSWktefK7RBmo7NcESkSe7Za3zKXCygy2nckubk1AP7Wu1QcvWPZfbflOZ-_xdt8bvw0nER-B5MCtmLm0v90efSSs8_rltFAbPZn4MdPrcAewbXySyXzvsJ9MAbdqOyJr9VH9jc9axB8T590njdOXievfnI9pOE3c4RLLxCCv_9axUhlsMBT14JO90t81qLqljXw7C81Hy6ZxvL9zbsX55PbvqlGoeXVqE0OqfLNcqtiKbe6CgiSkzuvNWZa3vISOJ2mA2lxIs-34m0kiZa5jKWqWTLCGlvVJ8EreasBw","e":"AQAB"},{"kid":"4xEkgx6qTwA","alg":"ES256","kty":"EC","crv":"P-256","key_ops":["verify"],"ext":true,"x":"7tCw6gbTlRcEWTN_N_V3J3wBum6p23QsZuF-g_Kykw8","y":"0gMebPPNl1-wDCLQJ_hFarEIUO-zEzYY99yOElwEu6I"},{"kid":"E6nqjhVU6QQ","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"2csprNTwk6ZxoghnZskO017idwamjP0pPsdSHKrfVQwsM37AcV3UH8J619PWDnCDmqUx3Tdn4tmFA2C3ceL01tlwvnzGvTIOBCfg3z6Gxy9_lkrGjyuHIaIBlMuL7Rik4G8FURdDOH4hlmnDWCq6lGyNyRkUK4J7mXVRmPOtC65Rcsv0MikgSj2Ov1_-UZgdT-X7C8JU0UGSzKDwpg2cZFqlIwj5HaPtKVyOPjHL4U6U_b4DKgo5XczHqjjRWkBzzFwgy8Rqa0itw0AwKdNknAE8egW2Xo08Zx34XqOyUjd0vaVZCuxt8l1FniPVOtGXaV_z-v4jMeEbFi_5kRbakw","e":"AQAB"},{"kid":"Eric3iwSzVM","alg":"ES256","kty":"EC","crv":"P-256","key_ops":["verify"],"ext":true,"x":"q6fkb33BMFPRDfh7dRYosSIObhP7tysGxTzCDp7qJqE","y":"_lYUI9MGiNP8hj-YNA7kVAHqZQgI8fQmj0MOR358wNI"}]}},"defaults":{},"registration":{"redirect_uris":["https://localhost:7000/api/oidc/rp/https%3A%2F%2Flocalhost%3A7000"],"client_id":"d6b702e839cc4d8a28a28bb240f6b477","client_secret":"c20ce8d163c2f889dbe9737844ec4373","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7000","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7000/goodbye"],"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDAwIiwiYXVkIjoiZDZiNzAyZTgzOWNjNGQ4YTI4YTI4YmIyNDBmNmI0NzciLCJzdWIiOiJkNmI3MDJlODM5Y2M0ZDhhMjhhMjhiYjI0MGY2YjQ3NyJ9.PXFI5laINQj1spjY9Fb9dI3N6Ex4sKQPUWQ7mmlXFXsNYtjSu0wKoFqvUwIx-epgfl_KZa5Jrr9itRM1xWh70aXTSa4FgppfvTY53TL8WuMQvtUHFCjkrraGXm8tZpY1CHSuP9K4mXI0g0-NOKNsqgLCsJOlwlyqrMaXH27nxRD5-fMaky_mR3-wO--ViCJR3qS7iZQYB3eacHiHtk_3NxKJEkMmfo37iN0dQ1WtDKSZsNUGm79cjYz4RDXWpXUvA9bKM53kdDCMvS89YpFnmWZE0vrRWzV8JTyg8dyneXfLBkoLS6BWptbVt39CfuV9he0uhskD0rNZhhup_PQ2Xg","registration_client_uri":"https://localhost:7000/register/d6b702e839cc4d8a28a28bb240f6b477","client_id_issued_at":1778163442,"client_secret_expires_at":0},"store":{}} \ No newline at end of file diff --git a/test/resources/accounts-scenario/bob/db/oidc/op/clients/_key_9d6d826150561f16b54246175dd4251c.json b/test/resources/accounts-scenario/bob/db/oidc/op/clients/_key_9d6d826150561f16b54246175dd4251c.json new file mode 100644 index 000000000..e9d6e71c1 --- /dev/null +++ b/test/resources/accounts-scenario/bob/db/oidc/op/clients/_key_9d6d826150561f16b54246175dd4251c.json @@ -0,0 +1 @@ +{"redirect_uris":["https://localhost:7001/api/oidc/rp/https%3A%2F%2Flocalhost%3A7001"],"client_id":"9d6d826150561f16b54246175dd4251c","client_secret":"08b0b7d8f5df66338b0a0ec78627ad76","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7001","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7001/goodbye"]} \ No newline at end of file diff --git a/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7001.json b/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7001.json new file mode 100644 index 000000000..a8af9e2ef --- /dev/null +++ b/test/resources/accounts-scenario/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7001.json @@ -0,0 +1 @@ +{"provider":{"url":"https://localhost:7001","configuration":{"issuer":"https://localhost:7001","jwks_uri":"https://localhost:7001/jwks","scopes_supported":["openid","offline_access","webid"],"response_types_supported":["code","code token","code id_token","id_token code","id_token","id_token token","code id_token token","none"],"token_types_supported":["legacyPop","dpop"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","ES256"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256","ES256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":["sub","iss","aud","exp","iat","webid"],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7001/session","end_session_endpoint":"https://localhost:7001/logout","authorization_endpoint":"https://localhost:7001/authorize","token_endpoint":"https://localhost:7001/token","userinfo_endpoint":"https://localhost:7001/userinfo","registration_endpoint":"https://localhost:7001/register"},"jwks":{"keys":[{"kid":"F0i8Cso8MiI","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"m7Ew3ZsHr0cbA2dbZ8J9cZm1hk5erzA0plKrSCpFZSXiHOlTJ7V-GVQkAMMLE6fgBG9tlWEaRlaCdkB2FSbzjLTegTJYbVDj1HC_M9w5utw6DqBAAC_2AYFLsJ6oOIdqdxj_1j9qmi0ajS8D8YYvVnepAaENlu48NJ8nFrFttxsnQYxyed18CAFrgKDt-v0sgWelvbz4uwdcsjbfV1_SccLd83X6_M1t4Yrk-JnfbwJN1bFQPSj7Bj65vMc3DHHjeb3Sf8cXqGEl9YPme91u9uFZcThwRCQMWs2KiElF7aiGXV-MlfskAYhYBufn_0B_CUvc7KJUeg6I72Q9w1WX2w","e":"AQAB"},{"kid":"V_a9og_dYVM","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"sAWXspQC5Z_R8QRUnZeAT7EdJlgWaXyW46SIUEZCCt-g-XO__QxgossLpgJ4Pg71YaBSpLqOosSTiVhafRKEIGIrw-oCL0I-IavcEDK0CoUuR6_Tia0575sWeDQ57N3DGLLOKjWYhQRp4TE0eu4CEuszRqT4LtwUj6q19188gIj5aEShsF4aTm5d5uh_17kwC5A2cNAJLldUbIUebzeRkqeDEEHc8uMVA_ETPf1UfG4d3cfuv_aL7E1X-DcZt9GMG4j2gv-qLxA_1bzm-wUvQEA8aaIvQ60fBAui6avlnGTNfEfFBfSe8DQdYL0qQgLR5e3WKVMMyzLT7fQeWi006w","e":"AQAB"},{"kid":"lzulHIimuhM","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"pFRaI8foJXbn2DyfV9e5gbNAL7UJnDKcCyoCzcFUYHslCUHjCEKsINlioWFfvKz4RMbOdPqXglwf_r-__fcvPHMfTWzyqmlkNwTHguF0Z69XCyqEwO1EeUskgpy6bWmer7LBaoS9A7P1gTrlyN6pYSl6A7_GtlI_0orM_zRiOWCdmoea0VtXCh5oMTorSTxRmQE2GHwfYt6uZSbND7Ka-mxQNaKSsvJ2vf6Li92KJDEfy7wJz3CUa--uJuazvj1WrJRMLj_lAh6QlWFOZtYr764JDf8fJNPIegqGSZ7GX5q-yjNAHODk46uJBT97V6E3ctOfCJG1fzY8Ppn53KVaJQ","e":"AQAB"},{"kid":"0niF1Tso2n4","alg":"ES256","kty":"EC","crv":"P-256","key_ops":["verify"],"ext":true,"x":"lOlV6ppchnrfcwzsRd3yvo9HVuL-HO1JGse-GTR5qtw","y":"XUGEgVTWndnDUMj0kTmGo0iP4z9_VFuNdML0nUO99XM"},{"kid":"Q01qIx53Jjk","alg":"ES384","kty":"EC","crv":"P-384","key_ops":["verify"],"ext":true,"x":"av5f-KpE389w_y0zaXfTRqP0VBEBTtufFWixOTT9A329K0UlWobL5jyguG5TOcoV","y":"1BMjRNlVwTiuNACJ8Ph7fiwDrY6TEmwlzfyko-3FQ8rH2X6BCP8t_RTsGHUMhq-v"},{"kid":"pm78MX066es","alg":"ES512","kty":"EC","crv":"P-521","key_ops":["verify"],"ext":true,"x":"AICvBCvEwWEUUcOfexackErSsRzvPJoXgJA76QL_gSkSDjLkNU_zChFo2muJw0ITz87lJTSX5lQ_uZkNFw_vbw3l","y":"AVgjYc-YovlcUk-BwMxRqRQjSYi2PLcctHjDxQ-bP0b2ndsgfKQ4Pe1qzrOiMW7V_iinkMnXhO6Hifdiz8FIYrBr"},{"kid":"pVu_kIHZN1I","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"0c9qz0voW9at8FcyOR5jAQT7fCfrN17xgXkBlldceXSjHDxPIzzC-wHj_zYd5PUdG0IgIxDGWV5STKTbWfDVFen1z4N0L8eToaC7xjL4yy0EwrZ8neKxK0QipB_D5LbXfu6_X5vuLQ1FyqyL4lVhNG0vK37q9YdDW33WinvsuNoIpH_g5_OS6bvQZKIdDNTwTg_fUd-1gngc9z2vh7KcaZUOTegXYxO9MMONSGAzkJVD_0Q-T32ZCEZ0p_2mw938W-8DqvXEnuWgtNlNBLSbf7milCLSIsbnDzxro6Nrr2hwUMhyYieIGLjs7lCqCQPFtKRFVuqVG6IE5vnjZmsAow","e":"AQAB"},{"kid":"aQCVgIZzNWY","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"oK9euvBhs6zbKtY1cIFfH-cwNGYg_UCPBfEJhvQebXTHKzCl4PFfMA7UfRRc1nRcA-S2m-Uzhcxbdg1xmAlhKljPja7Bo9vZycCVc9wxTcW0iT_Pbk30UNJaH73hLt2uTL7jzk_l8QUhL5vBq78SAaTx5B7ymA_O93Gx6zkMpWUbrZ4mFQXQtVwGolyJ0q1plpABxGR1WASfKd3wMjYvHzMmgeNPJrUUAKGM0JO4Liku7YJU5TMM6r3Dw7MUFUH8Y4wQds0YAwQy1pxohzBQ6LZHHa0JENPZI0rht2vUUXFUEdG0Z8QtrVRGk2szJvaIc4dm75BnWG7ia93UQRqWxw","e":"AQAB"},{"kid":"1CDA3Zf0X-0","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"vJREhRpl6Bs8q4dJKR_gpo38RPbfqtKtgU4Z1W87vfe37AppDPprAeaxjf8Pai3WsccCuwMKonxJnAnYq0hfBMrc8OePx8dny0JudEATL0CT0mqD1R8rt_EGLz_7rGGPKlvhFsu35SjW7_p-wi1julZRpKwqfl3eOCSqPD54ow9bDcPHfpUAMUtashXgiwKBTjbpTYhs60IPGCnWTG0VWGMDREUfcF2XAJro97BCTicdV7FXNZt2Sx40twgq5Snv-7XqhbdzuDYvcrje2IelTdPfA5r8S80c68s6kOAJ9vhPbC2IkLrjqT0X9Ka9By2P7dgwCMtzdcOySgHuBadKZw","e":"AQAB"},{"kid":"3xEUfSPBZrs","alg":"ES256","kty":"EC","crv":"P-256","key_ops":["verify"],"ext":true,"x":"J07P9pzOjetjblEIUXE3cVOgZnW-Of731h0Ql_I7h98","y":"anKW1ogx8gnSyJVD2-PFRuG2V63--lsqDwheSM5x26U"},{"kid":"dyEoKBDQpuc","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"uiu1FeW8phCiPl7LkjsOHysv6RLFrF-epOf6snoXHu39eds-Y3Mg_DSwA7tzOW9-Oj4nz_I_Vju0ZOOYUTzhhhH8fwAnulGPvULnB4MuXx_8t1BOE12BPUDxsVWCWHD-9jZ48oVSJQ2PSfd-0bZyeg_x95wVd_EKqOyI4kF4BTN8F-CwhZLALQgeELWNS5Wqdp6avrMXvMxtk4LQdxXs1THmx2UHlVR6KkpTvfM-QW_mVQUgd5CZaJshfiE80zIDmLh_aLWd8elHFziJ-PX5YIxmRpULoaZ3yXi8G5g0D37x9Jcr9UL_djV7B552-0-5kfw8s_gDqoM4CERiAEJNEQ","e":"AQAB"},{"kid":"Rq9hQzcUi2w","alg":"ES256","kty":"EC","crv":"P-256","key_ops":["verify"],"ext":true,"x":"ph4FewHBJszDIIcFHYDqf272GTCHGFOJ2IBTEn-HJ9M","y":"_vL4Leo5hDWdRDTSKEPM0HcDuam3CMe9UgsTOMacQWc"}]}},"defaults":{},"registration":{"redirect_uris":["https://localhost:7001/api/oidc/rp/https%3A%2F%2Flocalhost%3A7001"],"client_id":"9d6d826150561f16b54246175dd4251c","client_secret":"08b0b7d8f5df66338b0a0ec78627ad76","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7001","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7001/goodbye"],"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDAxIiwiYXVkIjoiOWQ2ZDgyNjE1MDU2MWYxNmI1NDI0NjE3NWRkNDI1MWMiLCJzdWIiOiI5ZDZkODI2MTUwNTYxZjE2YjU0MjQ2MTc1ZGQ0MjUxYyJ9.n_Uc296SHeNOHk5LsNRrhi7EDNFyB4MyCvGvu3NV_7ktAuA6IDZ3v3nouZEVT3Vv3qfW9g3phFwx7zua40j5MrCmcBBKavXVDpKeI0b7TvSihyXRbXQeVO64SjZnlrT5rqafAXp8gxwt_u7yvBTfQUo77QQgxTOwKykf9LtOCaISVlZcJI2KPZC3ywmJHqoZDQrTN3eTBfC6_CNYsAomoV9EynsqrW06B5ddRfkYYjllHWdUYhzy5v9b7vBFsQkd0jwjdGezpqfdjaF6UUaWu2Yb2jkEkvC6FmVtf1ymrYwHZGwuuvTlHAyH1EOWMThn-Cn3-FAMhC4SPCf8mnmRYw","registration_client_uri":"https://localhost:7001/register/9d6d826150561f16b54246175dd4251c","client_id_issued_at":1778163442,"client_secret_expires_at":0},"store":{}} \ No newline at end of file diff --git a/test/resources/accounts-scenario/charlie/db/oidc/op/clients/_key_ac9ef07887f8dc8b99d89884e8cb3a0c.json b/test/resources/accounts-scenario/charlie/db/oidc/op/clients/_key_ac9ef07887f8dc8b99d89884e8cb3a0c.json new file mode 100644 index 000000000..ad18701ba --- /dev/null +++ b/test/resources/accounts-scenario/charlie/db/oidc/op/clients/_key_ac9ef07887f8dc8b99d89884e8cb3a0c.json @@ -0,0 +1 @@ +{"redirect_uris":["https://localhost:5002/api/oidc/rp/https%3A%2F%2Flocalhost%3A5002"],"client_id":"ac9ef07887f8dc8b99d89884e8cb3a0c","client_secret":"61ae6c3c03967322130f7f860f77e95a","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:5002","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:5002/goodbye"]} \ No newline at end of file diff --git a/test/resources/accounts-scenario/charlie/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A5002.json b/test/resources/accounts-scenario/charlie/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A5002.json new file mode 100644 index 000000000..0820e93d5 --- /dev/null +++ b/test/resources/accounts-scenario/charlie/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A5002.json @@ -0,0 +1 @@ +{"provider":{"url":"https://localhost:5002","configuration":{"issuer":"https://localhost:5002","jwks_uri":"https://localhost:5002/jwks","scopes_supported":["openid","offline_access"],"response_types_supported":["code","code token","code id_token","id_token code","id_token","id_token token","code id_token token","none"],"token_types_supported":["legacyPop","dpop"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256"],"token_endpoint_auth_methods_supported":"client_secret_basic","token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":[],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:5002/session","end_session_endpoint":"https://localhost:5002/logout","authorization_endpoint":"https://localhost:5002/authorize","token_endpoint":"https://localhost:5002/token","userinfo_endpoint":"https://localhost:5002/userinfo","registration_endpoint":"https://localhost:5002/register"},"jwks":{"keys":[{"kid":"7gWaE5frJNQ","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"p39gNcNNJ0QcTqVf9BA2TDuR_33WDtFZ3Tg-9YX5SRR-Xk_il_UPRn1sKOm6jeD1TBrrq1qrWlWW55JLYo-L_pqrwDCafX3h-cTq2TR3TYuoLJCOZA_NiCqLmZz4BZeieqUD7p9bnBm_-8nXCJJmB8mIdtSTxpCocwxTACGMYzUiAf44V9MbMqWDaM3YnDSY9GGI8XjFXUCRaPCNQ9r4ShjS-LGp7-KAnA6W0gXDnd8PeHfvaQ0Etb4BelK4gC91fmoWv3qf2GFhFysRI6YnYPdWV7CxuqzZOVifGlbaA_uqo502ClHI0kPWY-yrvmfL1eDVUBivjsrq6Utiu0VTbw","e":"AQAB"},{"kid":"XlF2nR9degk","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"2c_z3_SmDrPysoITZ-aAanGqD_GpB_1Sy9I82WqJzD2q0oYUHptYcw4Wb6hZmdvpIxVLKGNoSgGkiO115n0Dy88k_OjWDaKDkv_svMuWP-R7AM8FULsthFb74ZvZKLC_hyBXIDNQc1BvZ26EvDP5o2GIIhV0GPVQXcBf0cnulp8qCZcReaWGFWFa90yWxErWMwbKzOx2EnDv0BgMJ53rC-bBhIQ7bb79xT-FyrJckTrHrR726G59ZvmnA2w_9YEDvx26_r65Ptc1aBsYQ8YNJEgQRo-il26872rcPFTRyvw-cBpJZQOhbIk2ABu4PQ8httZDHo-lI34TJ3RKmiBf2Q","e":"AQAB"},{"kid":"_nTbfXFBRRE","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"4xRw5CQtINM4VXTh-nSLNkae78fmQjn6-vb9a0ZthEYs9_37ilcueY_mwMqPrVLCmOHe5eTGpmNuEj56olWW3lo-9_02746HLg774rM0nzs-U0V6xljx9QPL2euSXyk9szPWY2k03QYDOFXM_4D4CTEkTBr46kG0w1QPb7XxUfy1_25KsL8yVpBTKuLfCMoHuzpQzy-lEwxC6J0HuhyUstsfOofMegbCLmJsx7W1wNCISvwHKlFnHUzu3TYKCK8vyDQVX0EsBxfpUwJzYcTDEuiL18JThQZuPyaA-6xR6mJPE-xkdiTimCNDni7OJ9kBTc3PGakhZHl-2H4u72eIbw","e":"AQAB"},{"kid":"e0XUdgLCKQ0","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"zexbDnW-OOXKalte4oZNS7gc-StOqzR0WfcBhCJ_Dt8YkH066nMa-MCEAkHK2aL7wOu5-Q01P1jY5nH-EEAYyIASqq8un7VTg6cODVR1EQDg917MI4ZrXZk_aM3e7MSE9eQMqTscRt6xSZ5zXwFm5T-5i_Dt6H9Y9WvxfKRoRqDjGQ7ggmlEFNl6VzVUO_lbAno8iogzO2kUdwAOa40P-vv2Fq8ghb5Zd-2bxfPKj_0QqGeunNzJJhHo3AjTMJeWcOw4oLVS_kJc7BXUtaqKcoULy2OPOjLjSJEUGBeTk13rzIso9Zq6Un_7hrUAVbDUjx2UNgWslSVrGEqMhuc63w","e":"AQAB"},{"kid":"AFReUbZy87Q","kty":"RSA","alg":"RS384","key_ops":["verify"],"ext":true,"n":"w3UzaINqE0-OH08jSUSqsEFHuuuwfNS2ukUMbzfkLCp56PfuFI9mFoKSVuzzAvN5NReLHVmn_Kdau87BNLll8iaFWePCN_LEseks6-szR1jDrDeOo0iG8rBHvTjF0KArZSFSsNhkuATF5j51-lGVKQKbtESwutm9h5EAwIjj2QsWY1HtpgjQqCZaavDMJXB6mh5L-TRCuUa_4MdEnQk3RT9gyPB4BSznz_F1Loe8-M1fFNiYj8b4_pe4gYlKfl_kQJ62EWW9LJvnrCLehCKqi5rPuNVM4IRgPry8MvL3CyY-3argBHQJ4oqDVtWzt_pez5P5irUyCVKGc8iWm-cPyw","e":"AQAB"},{"kid":"nCmnumEy0bU","kty":"RSA","alg":"RS512","key_ops":["verify"],"ext":true,"n":"xzFuXYjRscDSOlvkaWKCCaVNSCXeJTkDzjpkV9OHJpcMAbHugGUFXZN1YBrTc-3HL19i73vY1YsAXaDwi34vlnW-N_2v4kk4Ev6CRjnWWurEIS3YidY24nguh2ZLUtOykTd3pIADUq_DDJ2x16zszAQE-Qgx-aGJYdfarOXjNwZuXS-67hpGVn-9NUB-wcnZjEZdUDfQ6vCXQunV-wypZgZs9aaWR2C0721dB8Qwb7K85DKkDX9wzmxfq1h9OscMT2Qd3pTLjQtbgycyc6gsmcJPo9_roX_vqIpmipLWSPtWXIVvMo9-nyMqgjxx4niZYDZFdTPb7J3slS0jWf9Dsw","e":"AQAB"},{"kid":"y53ED_CQdbQ","kty":"RSA","alg":"RS256","key_ops":["verify"],"ext":true,"n":"qKDQVdmqdcREkV4UpDXojRe1Ui3aIOwjs41jOyV0nzOIsHNU9HXWcnOAHL_17irA9sve2UC8wP5kC3MLIyacCKdtvuS108VcLNs2FwJerMw01JGO7BZQ-K9In82HYcQUtHGFRMEd8_4-hVe-xNdNwd0RqK3T7oWg0qU_g7kHco6D6syb3CDGmMyf4lFex4UonYrnTrmvkSU0l62rRcat-94_c2GoJkQD6uLg4f2BK1wirntUgLwVufUkb8ikh8bEaN-x2zp1aalzDKxP6BQO8kFLk7NO8W4OffAK3nlxw6TGiSA-cwdhBDS11sneC5FylLJ0FwVWrNT4cPTz0n5I6Q","e":"AQAB"}]}},"defaults":{},"registration":{"redirect_uris":["https://localhost:5002/api/oidc/rp/https%3A%2F%2Flocalhost%3A5002"],"client_id":"ac9ef07887f8dc8b99d89884e8cb3a0c","client_secret":"61ae6c3c03967322130f7f860f77e95a","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:5002","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:5002/goodbye"],"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo1MDAyIiwiYXVkIjoiYWM5ZWYwNzg4N2Y4ZGM4Yjk5ZDg5ODg0ZThjYjNhMGMiLCJzdWIiOiJhYzllZjA3ODg3ZjhkYzhiOTlkODk4ODRlOGNiM2EwYyJ9.BEsKvXGWZXv_ulatGcb6fscqf67JObED_QyJ2cfgKDyagidwSNZ3AwoK89c-sSqpFBGY53IJSUd3xFYLmznyF-fP4byazy0dq-aUzpIBLt01kUiPyFQU0Kz-SGH_OvxsbUuHbKP4g7_sWq2xs-2tM7Puo0IP5AF8UR2EAko2K9Euwi-JMw9t68qR5v85FYQDrQu-GNLQ8olffUsqxyQR-W29PLV_KyCREjTUhNkOJbUFnEPgupH7k-MS4wcgeLcvwFk2tqy2enQXbj1jRfdebBsMvoNLus0pCKkrQuEDiOlwy6f9sWi-EYjk8wVVQuy76vB30FQXrGPEVdpJWpXwkg","registration_client_uri":"https://localhost:5002/register/ac9ef07887f8dc8b99d89884e8cb3a0c","client_id_issued_at":1778163453,"client_secret_expires_at":0},"store":{}} \ No newline at end of file diff --git a/test/resources/accounts-strict-origin-off/alice/db/oidc/op/clients/_key_75599c4707e29bba50a23309acf3109e.json b/test/resources/accounts-strict-origin-off/alice/db/oidc/op/clients/_key_75599c4707e29bba50a23309acf3109e.json new file mode 100644 index 000000000..ac56c8972 --- /dev/null +++ b/test/resources/accounts-strict-origin-off/alice/db/oidc/op/clients/_key_75599c4707e29bba50a23309acf3109e.json @@ -0,0 +1 @@ +{"redirect_uris":["https://localhost:7010/api/oidc/rp/https%3A%2F%2Flocalhost%3A7010"],"client_id":"75599c4707e29bba50a23309acf3109e","client_secret":"9cfdbaf76410e78287d7288fcce54f27","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7010","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7010/goodbye"]} \ No newline at end of file diff --git a/test/resources/accounts-strict-origin-off/alice/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7010.json b/test/resources/accounts-strict-origin-off/alice/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7010.json new file mode 100644 index 000000000..cc6674661 --- /dev/null +++ b/test/resources/accounts-strict-origin-off/alice/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7010.json @@ -0,0 +1 @@ +{"provider":{"url":"https://localhost:7010","configuration":{"issuer":"https://localhost:7010","jwks_uri":"https://localhost:7010/jwks","scopes_supported":["openid","offline_access"],"response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"token_types_supported":["legacyPop","dpop"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":[],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7010/session","end_session_endpoint":"https://localhost:7010/logout","authorization_endpoint":"https://localhost:7010/authorize","token_endpoint":"https://localhost:7010/token","userinfo_endpoint":"https://localhost:7010/userinfo","registration_endpoint":"https://localhost:7010/register"},"jwks":{"keys":[{"kid":"QMa5xaKOYwI","kty":"RSA","alg":"RS256","n":"ol8T6mc7t55kUI3Xf4pHsx8p25VqZ3jm54TQY6xZ07FYzwU8ex02Mg_W_VABRNyVq8wdUBKubo1W8iaKtvtrr0XDOyHUAlwM6xa14332c9akB1AmTirZY4gvyobIY-b18F8LpeIkkLUcypeZmsDd-bGONEJs0sxM6LtLCY41s_lgesPdQwmHLBw4_Rw9NcjBslupWP_pSXUW9x2fj8tKHOoURqmOWL-54t9YbDdIht06uzqagzjPV3UoYtvRsu2QUJx99ExgNvCA9pA1wyiyysyhUdfeKyZpwvTfwWxkrwZrE6zb1AZICFW5R3Bgg0b9UnS96LpFxbSO7rXBRWVKOw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"66gqKTPVHyA","kty":"RSA","alg":"RS384","n":"rWHV18o2T_9zhO-YknvC9brSjWru496-Dw0iVCiJdEZph94aWXeZW4tYsSjYAfzCJXQUrIqfoPTx100KoYRMiUGpVUh5AgwqAitOpFV_Cxa2j4D01qALwUXWblmJTeCzY7zQFOs1-OBU_U3fhsqcHVPHVlfwO4fYhe-FrhQwTDf55JIW5OGM2bViRxpPyZ3t9nueSuz1jEDCSRlaizJq4jhDrkYWoXtXomRfoseGrZVwTGx5cslfrYs-AmLWMfLkZQWjYZMcwOaJCy3dSkTQkmas4dzGwQqrOqNbgznyCo3oTpHboWS36e0jn_lnbq95zPpZkHNsOzw-Mzq0KRj1jw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"zPdTDVkjms4","kty":"RSA","alg":"RS512","n":"tbVEZHw4LP21DFHmF0QjbSektUEGl8t4blGcZM871ieppUVZ_uHKlfan2ftnQ4GwBISkmjwk6KP1nc9iiONzqAhJbFheQMT4RAmYz7xPJP9cAPOiD8YbqzO2xJqBT1OMc2PDS7ICH0-t_kBXMb0IR61CTG-CmO5Tp8ecauUkshvvYg0dA3-os16Dy5ex4if3ZSpfXowmfjRrvYAKEPeFTmA9Q8lMzN0ZyuEb2nILIeMFwTAjo8Ck8D1h4keOVaY0mfTgC9KxkR1kIcAc7DELYX-vjG63AwB-8IIo735g5NMrFj22923jymMDpQDTEMui-jW1TQYNGR84ShNW_nJZGQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"8zan03XlrmI","kty":"RSA","alg":"RS256","n":"y3E2ffizby9vClbahFgkg8_NcpA6iYTwkA4TH2i2-usyH87nz3uM5LfRa7KIV4ZHOvY-DZr6H1MAqaFCPrciWZnPPyTRxlUlYhfb4n42g0Bwk7Oh7z-KZvIqR45x85NfniR3uB8xu47VdTWCmWJge2glMavdWC8mr5Pc8um3Fr5eMVactUFsp571SrVKuY_u5qJHN3xmn7mLpiLdCIHFlBkvvLgcAcUmXFhn8cCzHCqkrcrRgfjhd5YJmv6wxxINrTMT4_dS4jxE5u1Ezw-m6xSIWukdW21lWupFHNZEK5VvIkiCBl73Sh0vO-gv4XNJcQmPRpqMdHat8GLG7I2PHQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"5ooqXseJdoQ","kty":"RSA","alg":"RS384","n":"tqhzPmOds6zvDNX3IHCr7SW5kf6GlnlpW-Rm4aQot1HmtKnsXieV99VjT98EspvcFeKyT5jcJFgZMHb1n_kOtRnhygFHNZQ4ooFmFI2VP5lPUuOIGppWiO5ZxIzECdnwAGoZ7A1hu9Vh5oJJP-m_88JX3sdbHPlsEpzdXHf-THZKAqPYHGNWeNPkmQuON5PaEwiJGRWQ0dbMZtsiMoVCPjh7ZQM-1N2UjXmSAcCCxst5BaTUwQQuVmKIT-cfxP9ShPjP7_DmpRBDOp7_frMKTPkznL0HPNWqSRRewGWB6AcrdY7WtWI5hHNDx9GTdLmolFOvnL4cgtr99u9adq6vAw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"tfz-MRyl3GQ","kty":"RSA","alg":"RS512","n":"3vyjzgsA89bJHD7BZ2MNadjFOPUXpuGKlC7OumOspC_iOSMnJ0Kh7oCWmsBTuzGdfY_gYMaXx28NK-LVjGmaEKZxPhdCz7yzofUCMWoVXNhE4hwBdZt1mNl0abaKNzJ_8BhbtTNhourfBvRwebMYFYO0LfEkApE-hM929pFERb0G3-_ybw8A2cqQFvQ67JhYsBcFUwpf68xZvfYTbdsJzfkkoY0UgI01GU55dZskPBK-_jndzo_bNo8wdENU_cUdOllNdZUoWAemWOGoHKdNshGKy6OwZaC9xqaE_VzF-RaYEcf_jaLD7GbPrz9Xcm9sygnFV3IDlj9-0fjKOkAwqw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"FRI7f7sJKUg","kty":"RSA","alg":"RS256","n":"0d4up4hx3l_HZa6D2PO-YYSqgNbAWjtlIbnFWIZS-PRzVFgDKcR9bLo_9-MGm0sT0tHbyeqc4V8o5oeYF0uhNmlEuU9Do2VYiyc0fkNeW1nFrC-XJYxbnge4Pxbhl-0Jn04O8IJsP15xq16GfRZxYfjKPeFilQNeDwTb_SBqoveZASHUW998eO1bEg0bjUZs4Yy23S4R23ABZKTQMPMGMETGFVbcFNXUohu0eSYMG4eU9iaFNmh0p0G9lU7ER-UFSatczaEFle0Awazb7-EHN6iya3zL6LDt-ezlIszTzHIXa2Dhhga2SlDIyuhFUOkkEeCP3a-sFeXre-JWiYz8ZQ","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{},"registration":{"redirect_uris":["https://localhost:7010/api/oidc/rp/https%3A%2F%2Flocalhost%3A7010"],"client_id":"75599c4707e29bba50a23309acf3109e","client_secret":"9cfdbaf76410e78287d7288fcce54f27","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7010","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7010/goodbye"],"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDEwIiwiYXVkIjoiNzU1OTljNDcwN2UyOWJiYTUwYTIzMzA5YWNmMzEwOWUiLCJzdWIiOiI3NTU5OWM0NzA3ZTI5YmJhNTBhMjMzMDlhY2YzMTA5ZSJ9.nVvZAxVmMs9rNUaZnUSj6oLRVTjtGdf3WvBKfo74vGM4bIYz59K3il0c24TbghKkahvhpzZvuxYyQs9k5hYHjQ1deh9bTsXXo6PvUuotKvrvAbtgNQ_CUPZSRF_cWK44q3Fyi8TLHYI9rce64lmhWRUBJCbKkyXg1TM05-moikZUZZjCjSInn9C3leRd75Kg_dXezbzxayeeAz-GT-kC0T0b9TuqkP1xqbi0U6nQ7G0aU5zZJ4BVUbwcEmmkiviS_9vziSn2s4YPgb7vOYjpBAczrs_2-uidNVzmse7R9kvDkSCaGAWDdqVoPrXfDURYmL_4BP2G4cmTfiB78mx4Ww","registration_client_uri":"https://localhost:7010/register/75599c4707e29bba50a23309acf3109e","client_id_issued_at":1778163444,"client_secret_expires_at":0},"store":{}} \ No newline at end of file diff --git a/test/resources/accounts-strict-origin-off/bob/db/oidc/op/clients/_key_3eaf33a137228329d60d6b6df7b00872.json b/test/resources/accounts-strict-origin-off/bob/db/oidc/op/clients/_key_3eaf33a137228329d60d6b6df7b00872.json new file mode 100644 index 000000000..518734257 --- /dev/null +++ b/test/resources/accounts-strict-origin-off/bob/db/oidc/op/clients/_key_3eaf33a137228329d60d6b6df7b00872.json @@ -0,0 +1 @@ +{"redirect_uris":["https://localhost:7011/api/oidc/rp/https%3A%2F%2Flocalhost%3A7011"],"client_id":"3eaf33a137228329d60d6b6df7b00872","client_secret":"918320575d81cac5a5908a0fad94ba50","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7011","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7011/goodbye"]} \ No newline at end of file diff --git a/test/resources/accounts-strict-origin-off/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7011.json b/test/resources/accounts-strict-origin-off/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7011.json new file mode 100644 index 000000000..07918336e --- /dev/null +++ b/test/resources/accounts-strict-origin-off/bob/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A7011.json @@ -0,0 +1 @@ +{"provider":{"url":"https://localhost:7011","configuration":{"issuer":"https://localhost:7011","jwks_uri":"https://localhost:7011/jwks","scopes_supported":["openid","offline_access"],"response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"token_types_supported":["legacyPop","dpop"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":[],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:7011/session","end_session_endpoint":"https://localhost:7011/logout","authorization_endpoint":"https://localhost:7011/authorize","token_endpoint":"https://localhost:7011/token","userinfo_endpoint":"https://localhost:7011/userinfo","registration_endpoint":"https://localhost:7011/register"},"jwks":{"keys":[{"kid":"QXv-I1cn_YU","kty":"RSA","alg":"RS256","n":"siMc_WZIcylWwv6_0mtLKKkH-jDLIsREie9KRannw_BxVvM96CaFlUHywVu1Nkyw_hqCYFXyr91JnQ7YN_fB7OpcBcx6rjbExlD_piewy2X_vXq5_0YN5a3aPb5P6JSmRF-nS257jdteucmEVJeFHzSqXAU2K5Y_2ZXse9hGTg9MJMnzof4jntb19q02E0qBOGeBqDoMXQU0RJB2uncqUzXp7EJeB4UVwvcS107LTYwPX7BAHwyGorpgWc_DYutZq-4DxycQgvIlO3_hGiFjaWhVOHHkO1DGcWTGOM9DcFels7Abf_XkCPIrgeALlOaqUe6dnK-yFCGwBeJhZupP_Q","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"TxrWAv0g4Yk","kty":"RSA","alg":"RS384","n":"7d5w1iTgbqF5L8wLqFxS72vXfhmOCRHxOzoxFuAJKDtgceguvIZKcA8Krlm4qE10_Fjm3v2BhTBqKaMWeMMqM2KPCeOOm_APcOEqHLltmU301ErcEU7XJFH90HFJVlPDMbuPvX1_0mi904J-TNQ_XX1s4d1OBgJbEn-4RfongbIQK2Fv1LRclZ3n1__dVikrnngSZEWazdidEMFDlI5KNJv6jbGdQAfPzLWzq_9Fl-wbmGu4Up6COLeQDdGybmPCv3a0id9-jVlyZRauUc2RtO3mJdQaOUZNOuDFe2WT0xmhPhoLXKEWhlF1udJGzm0beY7s0pwg4QjhGi7T75d3lQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"-tvrpKoUfPU","kty":"RSA","alg":"RS512","n":"sGoD0dZ3-F4NaAKQQrPhSGAbkBvJ0OsA3PQiwAhWXMrdOj0n9o2QDQfICdayIHcRwsisQaOz7B-L-pYcYFgMcdhj-yWOYVUMHXJCTqJeiz4zdGqjlCxkOF9ebauahRk_IoXp_hDyx3hqB98giajTn1gpW9G36Hcau4PutfM_jeyxi4iVeQSm8-p7qLCm-qD7ot0vzCqvsIt9cDvm9o5DVwG-LoAzeyZlEDmuDpFrLsFLz-D9rxLfSSdAethycrDA0Fhe1j7Az15NYUItJIkvyM5AsC6X0xyjO1LJWoJfbl9cbfaJQ0c3XKRc_NuxPYcDuM15gw0FaTkgUOYezuALPQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"DA4MU9NJoDo","kty":"RSA","alg":"RS256","n":"vaugR-F0yLkwgUyJPrIxjUrncPFoGlFyfPWYfVlU4OleYqNnEofoUNI2afIqRQlZgMgfxhknAi6IEhQKdNQGOAqzKoBxEvVK30x4XxMyVPMYYWbTCEEX2K5Kna0wJQ39rsajBWBRusxge_bLydt0dun4ZX4lv_BaCkdKn4TB6x3sQtwPyYrkOj7nykBiLzpcNGYKYbL5B5HGJHbcjXqX6ttvUn7B7E_idcyJbFfcwzrSj87Zdp5aj5LSiIqHtih8Sm2Mch5VQDT_U55UT4n8Rc9K_fJ1bQirXQdZZ9CgU4zcjXx8PnaEpeq18EcXxXsdfCN8o4EBUN7YWog1-65CLw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"re686ijoKyE","kty":"RSA","alg":"RS384","n":"p3SLWJT-PyQn6n-fvj_s5dNqRJQauAZCC8JFnwUUP6n_LUfCG5tpfRLzZ0yXtsaPfN-z9c8JbXIbsF0HJN3NLTaBOWaZ04yjl3S29_8y-KwOMKlOafuRbYy7sXkicvCnjXRIcV85pAewrpwwHBcI2wDPDbkflwI3m6nurThQAQBDxPWz83QnC_iQ_V3pJGoGYItUGH_sJC06mqATV05dVzHSS0VZSFhEoyEtpykZNnhzgZCOACi4V8P07y0jsxQtqdPYYpqt9qg0Tjp81r-tIIDY7LEWv949rbR3Oad-QBcQC0f4CEapT2nbcVcadODtqPv1pQzSL7sSJ42zATGSxQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"jGE5qoPDlnI","kty":"RSA","alg":"RS512","n":"-WuAFX1DLbsMHagMw_NDJA6yxCcIm2SDgbMmDKOwCN4dR0YWdwKzDJ31EeoakDg-T85HaU48R4Kcx1WQUYnly98vDymMFl4Xe6zS9je-Uh3IVfkYJV3pwVKr2gj1hrX7QglIktLg0o0iWulFe1K1NQ5u7Jbe7ZZhOaetlTFH8KUgS78fajfhcFtrXBliqxHTiBz2lMYCj2ntqX8K8ue3_lVLBXm53AJG3T-ymnUd-nxJ-NsdFMSIzQO1-KUona9OgxDpu1cFoQ-YzOteSz2OP58wDl3gL5urIhzK7Hvu3I3okzlL2TyCHmOhLn6HMHr2-mwEgsYYnsEQwzu6bbY8hw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"PhN1R2mze2k","kty":"RSA","alg":"RS256","n":"yGhTYJWPKU-n1gnPQPf1_Nr8HBJ6F-IT6PyxLJoURWrd_DvuW0EFQD1JolKAYWakuJAIWGtb8CXjRdzXrEVYNfshb-bBGYGotISxAD2z_KzEtcBya_TwywAQiMYildsAPRZR7HqvxnUw2iFUwec_gw6aBvu3rPcLnj6z2KKDJDFC1P4LHNDArh4EquhkgtzeD5h6-8cCI1iKpb-Op5nCe1DjUNg49L5NZhNtDlhCEb8P2LEbcV8vldIz9gJu0MSf94zzWu2JGsVtVQ29XPO8SQqrKQ5TKClmR1jIIGCF7t4AUoaaL6AA81F3cyEhKB3vN_D8DzEsr7tuwomLFZXmBw","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{},"registration":{"redirect_uris":["https://localhost:7011/api/oidc/rp/https%3A%2F%2Flocalhost%3A7011"],"client_id":"3eaf33a137228329d60d6b6df7b00872","client_secret":"918320575d81cac5a5908a0fad94ba50","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:7011","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:7011/goodbye"],"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDo3MDExIiwiYXVkIjoiM2VhZjMzYTEzNzIyODMyOWQ2MGQ2YjZkZjdiMDA4NzIiLCJzdWIiOiIzZWFmMzNhMTM3MjI4MzI5ZDYwZDZiNmRmN2IwMDg3MiJ9.vky-k3w1wAu5lN1834MqbiLb0fBvfARb3ARM6cwejWzUpW0ii31tzg0FZo2R3kceyUDFRqwCZOOUoza1YHAlVChyS62L3laEJzb_lGNmzzJJD9KHBc6XrNQvipOnmAZ8c3EmkSQ3DuDIEdixp9WjlYOcK20sX1irdg8-a5MfGivzbIRIjl7iubT8ZkVKtwgov-K57JEyF6KAe2ERf3bZfXWwcqtnWzE_JK9pnkU_-8BMNEZKgYc3JJu3PiPSG3nx4Lq5fJ_pi0WohQ57imwjXtXG6jZBqvrW-qOUF4rQtk-bn5PwUT6gUiiShyw1ZqdZ6S5kxA6Cn9BjOFULDAZY2Q","registration_client_uri":"https://localhost:7011/register/3eaf33a137228329d60d6b6df7b00872","client_id_issued_at":1778163444,"client_secret_expires_at":0},"store":{}} \ No newline at end of file diff --git a/test/resources/accounts/db/oidc/op/clients/_key_353ded7390dd130f44d728c8b8415d33.json b/test/resources/accounts/db/oidc/op/clients/_key_353ded7390dd130f44d728c8b8415d33.json new file mode 100644 index 000000000..b88a466c1 --- /dev/null +++ b/test/resources/accounts/db/oidc/op/clients/_key_353ded7390dd130f44d728c8b8415d33.json @@ -0,0 +1 @@ +{"redirect_uris":["https://localhost:3457/api/oidc/rp/https%3A%2F%2Flocalhost%3A3457"],"client_id":"353ded7390dd130f44d728c8b8415d33","client_secret":"88502d3929b2798fb249bce1d1fa642b","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:3457","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:3457/goodbye"]} \ No newline at end of file diff --git a/test/resources/accounts/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A3457.json b/test/resources/accounts/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A3457.json new file mode 100644 index 000000000..6198abfce --- /dev/null +++ b/test/resources/accounts/db/oidc/rp/clients/_key_https%3A%2F%2Flocalhost%3A3457.json @@ -0,0 +1 @@ +{"provider":{"url":"https://localhost:3457","configuration":{"issuer":"https://localhost:3457","jwks_uri":"https://localhost:3457/jwks","scopes_supported":["openid","offline_access"],"response_types_supported":["code","code token","code id_token","id_token","id_token token","code id_token token","none"],"token_types_supported":["legacyPop","dpop"],"response_modes_supported":["query","fragment"],"grant_types_supported":["authorization_code","implicit","refresh_token","client_credentials"],"subject_types_supported":["public"],"id_token_signing_alg_values_supported":["RS256","RS384","RS512","none"],"token_endpoint_auth_methods_supported":["client_secret_basic"],"token_endpoint_auth_signing_alg_values_supported":["RS256"],"display_values_supported":[],"claim_types_supported":["normal"],"claims_supported":[],"claims_parameter_supported":false,"request_parameter_supported":true,"request_uri_parameter_supported":false,"require_request_uri_registration":false,"check_session_iframe":"https://localhost:3457/session","end_session_endpoint":"https://localhost:3457/logout","authorization_endpoint":"https://localhost:3457/authorize","token_endpoint":"https://localhost:3457/token","userinfo_endpoint":"https://localhost:3457/userinfo","registration_endpoint":"https://localhost:3457/register"},"jwks":{"keys":[{"kid":"lNZOB-DPE1k","kty":"RSA","alg":"RS256","n":"uvih8HfZj7Wu5Y8knLHxRY6v7oHL2jXWD-B6hXCreYhwaG9EEUt6Rp94p8-JBug3ywo8C_9dNg0RtQLEttcIC_vhqqlJI3pZxpGKXuD9h7XK-PppFVvgnfIGADG0Z-WzbcGDxlefStohR31Hjw5U3ioG3VtXGAYbqlOHM1l2UgDMJwBD5qwFmPP8gp5E2WQKCsuLvxDuOrkAbSDjw2zaI3RRmbLzdj4QkGej8GXhBptgM9RwcKmnoXu0sUdlootmcdiEg74yQ9M6EshNMhiv4k_W0rl7RqVOEL2PsAdmdbF_iWL8a90rGYOEILBrlU6bBR2mTvjV_Hvq-ifFy1YAmQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"Y38YKDtydoE","kty":"RSA","alg":"RS384","n":"tfgZKLjc8UMIblfAlVibJI_2uAxDNprn2VVLebS0sp6d1mtCXQkMYLlJ6e-7kavl8we391Ovnq5bRgpsFRq_LtRX9MpVlfioAUHwWPEG-R6vrQjgo4uynVhI3UEPHyNmZA5J4u34HNVTfAgmquomwwOmOv29ZNRxuYP1kVtscz1JeFPwg6LA7BxWrLc9ev4FQR6tjJKdo2kdLjAXR92odbCzJZ_jdYT3vIVCexMHxhoKnqCImkhfgKbGXcPHXWcelmuA2tzBaLut-Jjo0nJVQjRNDqy0Gyac0TptwFIxaiyHeTqugolUmEaJSfBSLszIRdlOTIGPJ7zdg5dJFK_Lxw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"WyMVv6BJ5Dk","kty":"RSA","alg":"RS512","n":"5JDlpbm2TjSW1wpdUZc5NHOqVVrNH_GumoODK_mk-MqImaIRpdR9b1ZJrK6FrW7HIF2bXvebD7olmp9a1goqe-ILbL_ORmhzlhRtyhjWQ-UOZqK5yOXqXXGQXgmok6TN-s55A-h_g12A7Yk5Y5S8EVa9EA4Axwqvm-Q_AkH0yS1qJo6BXYXb1fx205ucx-Ccot2LEBfxv8M7NOFTa-_G-sNchiKQMRoLhbZtLbSK2R1jkqGciEiRSLeXNG4nDu7Wd91-vhBixA1McxnzW96mW8lQwNXXo4gNH7SjONtYLlPQhZVEbmsQmXrOQN8a5RDkybFOIsbucItizSE9V_D7WQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"UykSj_HLgFA","kty":"RSA","alg":"RS256","n":"u79eQlGJN2XFNR-uEmPVtrB_ENRqaS81o6m63tZ5-PwhGHCwJ7rfVnnnvf6Ij_p91Z9pNpWBIVyZcw6UmQIoIBH-3BfxdaqhBxX9bf_N78TKj8_HU5IYjGijale4gog3kj9W2tJJO7R9iA43msjwLRD7pbAHp1iKFJgVTSXJlyLRbC82Dj4ivsEgJjPGvZt16OsGP5myIQwXEGzSPcEI0R9daZE5iM6xFZosaJ8B77eU-Aj3ciwxUBPi5BSZi2P1ZsF4QgSj3N7ZLbVKNW4FFr84IamA2YI0D7PyyNAE2PUZT8n0jHWRJKunuZuy5mgBY8H41KdBI6gNJqY90nHeJw","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"BJDNTt8RpPE","kty":"RSA","alg":"RS384","n":"nXTd5AoT220nBkW6Zeax8caUI7_Tt0y4v9TEW8TOrzCVvhLBiKpQPjILUTfkGHzxPtysEzDQFSYdHWvg_fvGYItjJBunBMsKCNcb2_CDr2HXD6C0s62bAgct8bBSoaT1MLQ_3MaFKXSF3ZuB87X2B8CVUJ386HP2GY1kl54BuMdFELNZYhy9S_D0KHnQls52Vvb99X9WaYOyxvfr03PG-9EycnkWas5tn1pPFzT0DtJtBJ4IBtXQxTr98jpn_MCz1gRnMgzzkfSOcrMkkMXxePqxNINVKFXtRy7DaJiFOcCMbuK2RJUkSfY2uKcx0aKbp5Xhvix1W8N7c0Y90i6_6w","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"z8iijSOOIs4","kty":"RSA","alg":"RS512","n":"rPCHP9XeTGOLf1Ezxeq_bdGdvYQZa993YcSVudT0EN6drTWqjykhUVEkT4MGAvLvax38kLARbPUTgMUV9UckDDWn6lRq4q6IZ5pytNOieQKZHzjEmQGzlbnEn1F2m1i5SAfBL-qsnt5q2RXMAiIUXk9q1ChJEHJxOZxnRIoQMc7yTsjjSdtIZKePFiYFn0nsl3A234ByyIBRjzZeoYEtTQKjDR7fP9LO78oZAgpwoGqmfI4IltqQYkFoqrN8I8l1yiJGyuvZRgDXUZ2fxGOQx2WD4xvlFL2TOCfN1UaPE9R4JdbRLLAOf5u1Sqnh4XTjDBhBbVodsmmbtvk4wFo-GQ","e":"AQAB","key_ops":["verify"],"ext":true},{"kid":"zD76wa11A2Y","kty":"RSA","alg":"RS256","n":"nMaSioq1An1J3tbkmc-zRrR8lkbP-WUVRuYhDxQvV-OcBw1R6cdyCcoeFJ1zuUT7ne6BlU6GMPRHuRKaH0KuOaiktUYtXm06T_HvtKFgCQSAKjMUj_ZHfTAJP8ahUsIc0D995XKp7nIGRF7Iy7I24QQFPRh7PmGlREZ52GJgYQgbm020-sWani0MqHoUFBlWxZW9NEqY1c3brN_qWnzjRKly6Kkk3sW1XHPcRLvoHnHQ6TKXJ8pfl-bNjTfK6zq9fDCZ_TY3qQZy66yT_2XPO6X0GHTdJsZlCj7Jg0qrilTHUkJra1bppTSAtVSQnSmYt_IV8zOYiVdJ3kw2khPcKw","e":"AQAB","key_ops":["verify"],"ext":true}]}},"defaults":{},"registration":{"redirect_uris":["https://localhost:3457/api/oidc/rp/https%3A%2F%2Flocalhost%3A3457"],"client_id":"353ded7390dd130f44d728c8b8415d33","client_secret":"88502d3929b2798fb249bce1d1fa642b","response_types":["code","id_token token","code id_token token"],"grant_types":["authorization_code","implicit","refresh_token","client_credentials"],"application_type":"web","client_name":"Solid OIDC RP for https://localhost:3457","id_token_signed_response_alg":"RS256","token_endpoint_auth_method":"client_secret_basic","default_max_age":86400,"post_logout_redirect_uris":["https://localhost:3457/goodbye"],"registration_access_token":"eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2xvY2FsaG9zdDozNDU3IiwiYXVkIjoiMzUzZGVkNzM5MGRkMTMwZjQ0ZDcyOGM4Yjg0MTVkMzMiLCJzdWIiOiIzNTNkZWQ3MzkwZGQxMzBmNDRkNzI4YzhiODQxNWQzMyJ9.T3GvvOoae9GHNfRUGataebqlavMflwMJY9VqDOON-eCxfmAWJrnEjHEmPgS2PGSM67Sq-cGtsYg24-ZwWsa2VC9Gi_MvrOyae7WLBc4X1_8dAYAlGtDd5Lv8joaK9B96C0yFZd-REEDiuXlAFqKlnQZUtP7zqhxMLYJUslmC3VcIG4PxLb6Gk9VdpgUjBJHo7SB_RXXOd3Hw4m6F7eAw4rpMBr2Siba2ebQ-CyMH9ueVtr2X5p3NnlR6GIIal_KbXK5GbXStEX1gnS0F-22-Y0JwbQGRQF24XsXHtxD7ogIFwVAaEYEJiqtAHkx8ZggToYIUwpk58YdYXuUi36stKA","registration_client_uri":"https://localhost:3457/register/353ded7390dd130f44d728c8b8415d33","client_id_issued_at":1778163445,"client_secret_expires_at":0},"store":{}} \ No newline at end of file diff --git a/default-templates/emails/delete-account.mjs b/test/resources/config/templates/emails/delete-account.cjs similarity index 62% rename from default-templates/emails/delete-account.mjs rename to test/resources/config/templates/emails/delete-account.cjs index c8c98d915..9ef228651 100644 --- a/default-templates/emails/delete-account.mjs +++ b/test/resources/config/templates/emails/delete-account.cjs @@ -1,31 +1,49 @@ -export function render (data) { - return { - subject: 'Delete Solid-account request', - - /** - * Text version - */ - text: `Hi, - -We received a request to delete your Solid account, ${data.webId} - -To delete your account, click on the following link: - -${data.deleteUrl} - -If you did not mean to delete your account, ignore this email.`, - - /** - * HTML version - */ - html: `

Hi,

- -

We received a request to delete your Solid account, ${data.webId}

- -

To delete your account, click on the following link:

- -

${data.deleteUrl}

- -

If you did not mean to delete your account, ignore this email.

` - } -} +'use strict' + +/** + * Returns a partial Email object (minus the `to` and `from` properties), + * suitable for sending with Nodemailer. + * + * Used to send a Delete Account email, upon user request + * + * @param data {Object} + * + * @param data.deleteUrl {string} + * @param data.webId {string} + * + * @return {Object} + */ +function render (data) { + return { + subject: 'Delete Solid-account request', + + /** + * Text version + */ + text: `Hi, + +We received a request to delete your Solid account, ${data.webId} + +To delete your account, click on the following link: + +${data.deleteUrl} + +If you did not mean to delete your account, ignore this email.`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We received a request to delete your Solid account, ${data.webId}

+ +

To delete your account, click on the following link:

+ +

${data.deleteUrl}

+ +

If you did not mean to delete your account, ignore this email.

+` + } +} + +module.exports.render = render diff --git a/test/resources/config/templates/emails/delete-account.js b/test/resources/config/templates/emails/delete-account.js index 9ef228651..95feba7f5 100644 --- a/test/resources/config/templates/emails/delete-account.js +++ b/test/resources/config/templates/emails/delete-account.js @@ -1,19 +1,4 @@ -'use strict' - -/** - * Returns a partial Email object (minus the `to` and `from` properties), - * suitable for sending with Nodemailer. - * - * Used to send a Delete Account email, upon user request - * - * @param data {Object} - * - * @param data.deleteUrl {string} - * @param data.webId {string} - * - * @return {Object} - */ -function render (data) { +export function render (data) { return { subject: 'Delete Solid-account request', @@ -41,9 +26,6 @@ If you did not mean to delete your account, ignore this email.`,

${data.deleteUrl}

-

If you did not mean to delete your account, ignore this email.

-` +

If you did not mean to delete your account, ignore this email.

` } } - -module.exports.render = render diff --git a/default-templates/emails/invalid-username.mjs b/test/resources/config/templates/emails/invalid-username.cjs similarity index 88% rename from default-templates/emails/invalid-username.mjs rename to test/resources/config/templates/emails/invalid-username.cjs index 7f0351d77..8a7497fc5 100644 --- a/default-templates/emails/invalid-username.mjs +++ b/test/resources/config/templates/emails/invalid-username.cjs @@ -1,27 +1,30 @@ -export function render (data) { - return { - subject: `Invalid username for account ${data.accountUri}`, - - /** - * Text version - */ - text: `Hi, - -We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy. - -This account has been set to be deleted at ${data.dateOfRemoval}. - -${data.supportEmail ? `Please contact ${data.supportEmail} if you want to move your account.` : ''}`, - - /** - * HTML version - */ - html: `

Hi,

- -

We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy.

- -

This account has been set to be deleted at ${data.dateOfRemoval}.

- -${data.supportEmail ? `

Please contact ${data.supportEmail} if you want to move your account.

` : ''}` - } -} +module.exports.render = render + +function render (data) { + return { + subject: `Invalid username for account ${data.accountUri}`, + + /** + * Text version + */ + text: `Hi, + +We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy. + +This account has been set to be deleted at ${data.dateOfRemoval}. + +${data.supportEmail ? `Please contact ${data.supportEmail} if you want to move your account.` : ''}`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We're sorry to inform you that the username for account ${data.accountUri} is not allowed after changes to username policy.

+ +

This account has been set to be deleted at ${data.dateOfRemoval}.

+ +${data.supportEmail ? `

Please contact ${data.supportEmail} if you want to move your account.

` : ''} +` + } +} diff --git a/test/resources/config/templates/emails/invalid-username.js b/test/resources/config/templates/emails/invalid-username.js index 8a7497fc5..7303c31bb 100644 --- a/test/resources/config/templates/emails/invalid-username.js +++ b/test/resources/config/templates/emails/invalid-username.js @@ -1,6 +1,4 @@ -module.exports.render = render - -function render (data) { +export function render (data) { return { subject: `Invalid username for account ${data.accountUri}`, @@ -24,7 +22,6 @@ ${data.supportEmail ? `Please contact ${data.supportEmail} if you want to move y

This account has been set to be deleted at ${data.dateOfRemoval}.

-${data.supportEmail ? `

Please contact ${data.supportEmail} if you want to move your account.

` : ''} -` +${data.supportEmail ? `

Please contact ${data.supportEmail} if you want to move your account.

` : ''}` } } diff --git a/test/resources/config/templates/emails/reset-password.mjs b/test/resources/config/templates/emails/reset-password.cjs similarity index 62% rename from test/resources/config/templates/emails/reset-password.mjs rename to test/resources/config/templates/emails/reset-password.cjs index 8c76e240e..fb18972cc 100644 --- a/test/resources/config/templates/emails/reset-password.mjs +++ b/test/resources/config/templates/emails/reset-password.cjs @@ -1,31 +1,49 @@ -export function render (data) { - return { - subject: 'Account password reset', - - /** - * Text version - */ - text: `Hi, - -We received a request to reset your password for your Solid account, ${data.webId} - -To reset your password, click on the following link: - -${data.resetUrl} - -If you did not mean to reset your password, ignore this email, your password will not change.`, - - /** - * HTML version - */ - html: `

Hi,

- -

We received a request to reset your password for your Solid account, ${data.webId}

- -

To reset your password, click on the following link:

- -

${data.resetUrl}

- -

If you did not mean to reset your password, ignore this email, your password will not change.

` - } -} +'use strict' + +/** + * Returns a partial Email object (minus the `to` and `from` properties), + * suitable for sending with Nodemailer. + * + * Used to send a Reset Password email, upon user request + * + * @param data {Object} + * + * @param data.resetUrl {string} + * @param data.webId {string} + * + * @return {Object} + */ +function render (data) { + return { + subject: 'Account password reset', + + /** + * Text version + */ + text: `Hi, + +We received a request to reset your password for your Solid account, ${data.webId} + +To reset your password, click on the following link: + +${data.resetUrl} + +If you did not mean to reset your password, ignore this email, your password will not change.`, + + /** + * HTML version + */ + html: `

Hi,

+ +

We received a request to reset your password for your Solid account, ${data.webId}

+ +

To reset your password, click on the following link:

+ +

${data.resetUrl}

+ +

If you did not mean to reset your password, ignore this email, your password will not change.

+` + } +} + +module.exports.render = render diff --git a/test/resources/config/templates/emails/reset-password.js b/test/resources/config/templates/emails/reset-password.js index fb18972cc..ba002c026 100644 --- a/test/resources/config/templates/emails/reset-password.js +++ b/test/resources/config/templates/emails/reset-password.js @@ -1,19 +1,4 @@ -'use strict' - -/** - * Returns a partial Email object (minus the `to` and `from` properties), - * suitable for sending with Nodemailer. - * - * Used to send a Reset Password email, upon user request - * - * @param data {Object} - * - * @param data.resetUrl {string} - * @param data.webId {string} - * - * @return {Object} - */ -function render (data) { +export function render (data) { return { subject: 'Account password reset', @@ -41,9 +26,6 @@ If you did not mean to reset your password, ignore this email, your password wil

${data.resetUrl}

-

If you did not mean to reset your password, ignore this email, your password will not change.

-` +

If you did not mean to reset your password, ignore this email, your password will not change.

` } } - -module.exports.render = render diff --git a/default-templates/emails/welcome.mjs b/test/resources/config/templates/emails/welcome.cjs similarity index 50% rename from default-templates/emails/welcome.mjs rename to test/resources/config/templates/emails/welcome.cjs index eec8581e0..bce554462 100644 --- a/default-templates/emails/welcome.mjs +++ b/test/resources/config/templates/emails/welcome.cjs @@ -1,23 +1,39 @@ -export function render (data) { - return { - subject: 'Welcome to Solid', - - /** - * Text version of the Welcome email - */ - text: `Welcome to Solid! - -Your account has been created. - -Your Web Id: ${data.webid}`, - - /** - * HTML version of the Welcome email - */ - html: `

Welcome to Solid!

- -

Your account has been created.

- -

Your Web Id: ${data.webid}

` - } -} +'use strict' + +/** + * Returns a partial Email object (minus the `to` and `from` properties), + * suitable for sending with Nodemailer. + * + * Used to send a Welcome email after a new user account has been created. + * + * @param data {Object} + * + * @param data.webid {string} + * + * @return {Object} + */ +function render (data) { + return { + subject: 'Welcome to Solid', + + /** + * Text version of the Welcome email + */ + text: `Welcome to Solid! + +Your account has been created. + +Your Web Id: ${data.webid}`, + + /** + * HTML version of the Welcome email + */ + html: `

Welcome to Solid!

+ +

Your account has been created.

+ +

Your Web Id: ${data.webid}

` + } +} + +module.exports.render = render diff --git a/test/resources/config/templates/emails/welcome.js b/test/resources/config/templates/emails/welcome.js index bce554462..8edb1c740 100644 --- a/test/resources/config/templates/emails/welcome.js +++ b/test/resources/config/templates/emails/welcome.js @@ -1,18 +1,4 @@ -'use strict' - -/** - * Returns a partial Email object (minus the `to` and `from` properties), - * suitable for sending with Nodemailer. - * - * Used to send a Welcome email after a new user account has been created. - * - * @param data {Object} - * - * @param data.webid {string} - * - * @return {Object} - */ -function render (data) { +export function render (data) { return { subject: 'Welcome to Solid', @@ -35,5 +21,3 @@ Your Web Id: ${data.webid}`,

Your Web Id: ${data.webid}

` } } - -module.exports.render = render diff --git a/test/resources/config/templates/server/index.html b/test/resources/config/templates/server/index.html index 85158e1e3..907ef6ac4 100644 --- a/test/resources/config/templates/server/index.html +++ b/test/resources/config/templates/server/index.html @@ -48,7 +48,7 @@

Server info

- + diff --git a/test/test-helpers.mjs b/test/test-helpers.js similarity index 90% rename from test/test-helpers.mjs rename to test/test-helpers.js index f9359241e..01b6e941a 100644 --- a/test/test-helpers.mjs +++ b/test/test-helpers.js @@ -1,63 +1,63 @@ -// ESM Test Configuration -import { performance as perf } from 'perf_hooks' -export const testConfig = { - timeout: 10000, - slow: 2000, - nodeOptions: '--experimental-loader=esmock' -} - -// Utility to create test servers with ESM modules -export async function createTestServer (options = {}) { - const { default: createApp } = await import('../index.mjs') - - const defaultOptions = { - port: 0, // Random port - serverUri: 'https://localhost', - webid: true, - multiuser: false, - ...options - } - - const app = createApp(defaultOptions) - return app -} - -// Utility to test ESM import functionality -export async function testESMImport (modulePath) { - try { - const module = await import(modulePath) - return { - success: true, - module, - hasDefault: 'default' in module, - namedExports: Object.keys(module).filter(key => key !== 'default') - } - } catch (error) { - return { - success: false, - error: error.message - } - } -} - -// Performance measurement utilities -export class PerformanceTimer { - constructor () { - this.startTime = null - this.endTime = null - } - - start () { - this.startTime = perf.now() - return this - } - - end () { - this.endTime = perf.now() - return this.duration - } - - get duration () { - return this.endTime - this.startTime - } -} +// ESM Test Configuration +import { performance as perf } from 'perf_hooks' +export const testConfig = { + timeout: 10000, + slow: 2000, + nodeOptions: '--experimental-loader=esmock' +} + +// Utility to create test servers with ESM modules +export async function createTestServer (options = {}) { + const { default: createApp } = await import('../index.js') + + const defaultOptions = { + port: 0, // Random port + serverUri: 'https://localhost', + webid: true, + multiuser: false, + ...options + } + + const app = createApp(defaultOptions) + return app +} + +// Utility to test ESM import functionality +export async function testESMImport (modulePath) { + try { + const module = await import(modulePath) + return { + success: true, + module, + hasDefault: 'default' in module, + namedExports: Object.keys(module).filter(key => key !== 'default') + } + } catch (error) { + return { + success: false, + error: error.message + } + } +} + +// Performance measurement utilities +export class PerformanceTimer { + constructor () { + this.startTime = null + this.endTime = null + } + + start () { + this.startTime = perf.now() + return this + } + + end () { + this.endTime = perf.now() + return this.duration + } + + get duration () { + return this.endTime - this.startTime + } +} diff --git a/test/unit/account-manager-test.mjs b/test/unit/account-manager-test.js similarity index 94% rename from test/unit/account-manager-test.mjs rename to test/unit/account-manager-test.js index d7b7e758e..cef6558d4 100644 --- a/test/unit/account-manager-test.mjs +++ b/test/unit/account-manager-test.js @@ -1,610 +1,610 @@ -import { describe, it, beforeEach } from 'mocha' -import { fileURLToPath } from 'url' -import path from 'path' -import chai from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' -import dirtyChai from 'dirty-chai' - -// Import CommonJS modules that haven't been converted yet -import rdf from 'rdflib' -import vocab from 'solid-namespace' - -// Import ESM modules (assuming they exist or will be created) -import LDP from '../../lib/ldp.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' -import UserAccount from '../../lib/models/user-account.mjs' -import TokenService from '../../lib/services/token-service.mjs' -import WebIdTlsCertificate from '../../lib/models/webid-tls-certificate.mjs' -import ResourceMapper from '../../lib/resource-mapper.mjs' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const { expect } = chai -chai.use(sinonChai) -chai.use(dirtyChai) -chai.should() -const ns = vocab(rdf) - -const testAccountsDir = path.join(__dirname, '../resources/accounts') - -let host - -beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) -}) - -describe('AccountManager', () => { - describe('from()', () => { - it('should init with passed in options', () => { - const config = { - host, - authMethod: 'oidc', - multiuser: true, - store: {}, - emailService: {}, - tokenService: {} - } - - const mgr = AccountManager.from(config) - expect(mgr.host).to.equal(config.host) - expect(mgr.authMethod).to.equal(config.authMethod) - expect(mgr.multiuser).to.equal(config.multiuser) - expect(mgr.store).to.equal(config.store) - expect(mgr.emailService).to.equal(config.emailService) - expect(mgr.tokenService).to.equal(config.tokenService) - }) - - it('should error if no host param is passed in', () => { - expect(() => { AccountManager.from() }) - .to.throw(/AccountManager requires a host instance/) - }) - }) - - describe('accountUriFor', () => { - it('should compose account uri for an account in multi user mode', () => { - const options = { - multiuser: true, - host: SolidHost.from({ serverUri: 'https://localhost' }) - } - const mgr = AccountManager.from(options) - - const webId = mgr.accountUriFor('alice') - expect(webId).to.equal('https://alice.localhost') - }) - - it('should compose account uri for an account in single user mode', () => { - const options = { - multiuser: false, - host: SolidHost.from({ serverUri: 'https://localhost' }) - } - const mgr = AccountManager.from(options) - - const webId = mgr.accountUriFor('alice') - expect(webId).to.equal('https://localhost') - }) - }) - - describe('accountWebIdFor()', () => { - it('should compose a web id uri for an account in multi user mode', () => { - const options = { - multiuser: true, - host: SolidHost.from({ serverUri: 'https://localhost' }) - } - const mgr = AccountManager.from(options) - const webId = mgr.accountWebIdFor('alice') - expect(webId).to.equal('https://alice.localhost/profile/card#me') - }) - - it('should compose a web id uri for an account in single user mode', () => { - const options = { - multiuser: false, - host: SolidHost.from({ serverUri: 'https://localhost' }) - } - const mgr = AccountManager.from(options) - const webId = mgr.accountWebIdFor('alice') - expect(webId).to.equal('https://localhost/profile/card#me') - }) - }) - - describe('accountDirFor()', () => { - it('should match the solid root dir config, in single user mode', () => { - const multiuser = false - const resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - includeHost: multiuser, - rootPath: testAccountsDir - }) - const store = new LDP({ multiuser, resourceMapper }) - const options = { multiuser, store, host } - const accountManager = AccountManager.from(options) - - const accountDir = accountManager.accountDirFor('alice') - expect(accountDir).to.equal(store.resourceMapper._rootPath) - }) - - it('should compose the account dir in multi user mode', () => { - const multiuser = true - const resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - includeHost: multiuser, - rootPath: testAccountsDir - }) - const store = new LDP({ multiuser, resourceMapper }) - const host = SolidHost.from({ serverUri: 'https://localhost' }) - const options = { multiuser, store, host } - const accountManager = AccountManager.from(options) - - const accountDir = accountManager.accountDirFor('alice') - const expectedPath = path.join(testAccountsDir, 'alice.localhost') - expect(path.normalize(accountDir)).to.equal(path.normalize(expectedPath)) - }) - }) - - describe('userAccountFrom()', () => { - describe('in multi user mode', () => { - const multiuser = true - let options, accountManager - - beforeEach(() => { - options = { host, multiuser } - accountManager = AccountManager.from(options) - }) - - it('should throw an error if no username is passed', () => { - expect(() => { - accountManager.userAccountFrom({}) - }).to.throw(/Username or web id is required/) - }) - - it('should init webId from param if no username is passed', () => { - const userData = { webId: 'https://example.com' } - const newAccount = accountManager.userAccountFrom(userData) - expect(newAccount.webId).to.equal(userData.webId) - }) - - it('should derive the local account id from username, for external webid', () => { - const userData = { - externalWebId: 'https://alice.external.com/profile#me', - username: 'user1' - } - - const newAccount = accountManager.userAccountFrom(userData) - - expect(newAccount.username).to.equal('user1') - expect(newAccount.webId).to.equal('https://alice.external.com/profile#me') - expect(newAccount.externalWebId).to.equal('https://alice.external.com/profile#me') - expect(newAccount.localAccountId).to.equal('user1.example.com/profile/card#me') - }) - - it('should use the external web id as username if no username given', () => { - const userData = { - externalWebId: 'https://alice.external.com/profile#me' - } - - const newAccount = accountManager.userAccountFrom(userData) - - expect(newAccount.username).to.equal('https://alice.external.com/profile#me') - expect(newAccount.webId).to.equal('https://alice.external.com/profile#me') - expect(newAccount.externalWebId).to.equal('https://alice.external.com/profile#me') - }) - }) - - describe('in single user mode', () => { - const multiuser = false - let options, accountManager - - beforeEach(() => { - options = { host, multiuser } - accountManager = AccountManager.from(options) - }) - - it('should not throw an error if no username is passed', () => { - expect(() => { - accountManager.userAccountFrom({}) - }).to.not.throw(Error) - }) - }) - }) - - describe('addCertKeyToProfile()', () => { - let accountManager, certificate, userAccount, profileGraph - - beforeEach(() => { - const options = { host } - accountManager = AccountManager.from(options) - userAccount = accountManager.userAccountFrom({ username: 'alice' }) - certificate = WebIdTlsCertificate.fromSpkacPost('1234', userAccount, host) - profileGraph = {} - }) - - it('should fetch the profile graph', () => { - accountManager.getProfileGraphFor = sinon.stub().returns(Promise.resolve()) - accountManager.addCertKeyToGraph = sinon.stub() - accountManager.saveProfileGraph = sinon.stub() - - return accountManager.addCertKeyToProfile(certificate, userAccount) - .then(() => { - expect(accountManager.getProfileGraphFor).to - .have.been.calledWith(userAccount) - }) - }) - - it('should add the cert key to the account graph', () => { - accountManager.getProfileGraphFor = sinon.stub() - .returns(Promise.resolve(profileGraph)) - accountManager.addCertKeyToGraph = sinon.stub() - accountManager.saveProfileGraph = sinon.stub() - - return accountManager.addCertKeyToProfile(certificate, userAccount) - .then(() => { - expect(accountManager.addCertKeyToGraph).to - .have.been.calledWith(certificate, profileGraph) - expect(accountManager.addCertKeyToGraph).to - .have.been.calledAfter(accountManager.getProfileGraphFor) - }) - }) - - it('should save the modified graph to the profile doc', () => { - accountManager.getProfileGraphFor = sinon.stub() - .returns(Promise.resolve(profileGraph)) - accountManager.addCertKeyToGraph = sinon.stub() - .returns(Promise.resolve(profileGraph)) - accountManager.saveProfileGraph = sinon.stub() - - return accountManager.addCertKeyToProfile(certificate, userAccount) - .then(() => { - expect(accountManager.saveProfileGraph).to - .have.been.calledWith(profileGraph, userAccount) - expect(accountManager.saveProfileGraph).to - .have.been.calledAfter(accountManager.addCertKeyToGraph) - }) - }) - }) - - describe('getProfileGraphFor()', () => { - it('should throw an error if webId is missing', (done) => { - const emptyUserData = {} - const userAccount = UserAccount.from(emptyUserData) - const options = { host, multiuser: true } - const accountManager = AccountManager.from(options) - - accountManager.getProfileGraphFor(userAccount) - .catch(error => { - expect(error.message).to - .equal('Cannot fetch profile graph, missing WebId URI') - done() - }) - }) - - it('should fetch the profile graph via LDP store', () => { - const store = { - getGraph: sinon.stub().returns(Promise.resolve()) - } - const webId = 'https://alice.example.com/#me' - const profileHostUri = 'https://alice.example.com/' - - const userData = { webId } - const userAccount = UserAccount.from(userData) - const options = { host, multiuser: true, store } - const accountManager = AccountManager.from(options) - - expect(userAccount.webId).to.equal(webId) - - return accountManager.getProfileGraphFor(userAccount) - .then(() => { - expect(store.getGraph).to.have.been.calledWith(profileHostUri) - }) - }) - }) - - describe('saveProfileGraph()', () => { - it('should save the profile graph via the LDP store', () => { - const store = { - putGraph: sinon.stub().returns(Promise.resolve()) - } - const webId = 'https://alice.example.com/#me' - const profileHostUri = 'https://alice.example.com/' - - const userData = { webId } - const userAccount = UserAccount.from(userData) - const options = { host, multiuser: true, store } - const accountManager = AccountManager.from(options) - const profileGraph = rdf.graph() - - return accountManager.saveProfileGraph(profileGraph, userAccount) - .then(() => { - expect(store.putGraph).to.have.been.calledWith(profileGraph, profileHostUri) - }) - }) - }) - - describe('rootAclFor()', () => { - it('should return the server root .acl in single user mode', () => { - const resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - rootPath: process.cwd(), - includeHost: false - }) - const store = new LDP({ suffixAcl: '.acl', multiuser: false, resourceMapper }) - const options = { host, multiuser: false, store } - const accountManager = AccountManager.from(options) - - const userAccount = UserAccount.from({ username: 'alice' }) - - const rootAclUri = accountManager.rootAclFor(userAccount) - - expect(rootAclUri).to.equal('https://example.com/.acl') - }) - - it('should return the profile root .acl in multi user mode', () => { - const resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - rootPath: process.cwd(), - includeHost: true - }) - const store = new LDP({ suffixAcl: '.acl', multiuser: true, resourceMapper }) - const options = { host, multiuser: true, store } - const accountManager = AccountManager.from(options) - - const userAccount = UserAccount.from({ username: 'alice' }) - - const rootAclUri = accountManager.rootAclFor(userAccount) - - expect(rootAclUri).to.equal('https://alice.example.com/.acl') - }) - }) - - describe('loadAccountRecoveryEmail()', () => { - it('parses and returns the agent mailto from the root acl', () => { - const userAccount = UserAccount.from({ username: 'alice' }) - - const rootAclGraph = rdf.graph() - rootAclGraph.add( - rdf.namedNode('https://alice.example.com/.acl#owner'), - ns.acl('agent'), - rdf.namedNode('mailto:alice@example.com') - ) - - const store = { - suffixAcl: '.acl', - getGraph: sinon.stub().resolves(rootAclGraph) - } - - const options = { host, multiuser: true, store } - const accountManager = AccountManager.from(options) - - return accountManager.loadAccountRecoveryEmail(userAccount) - .then(recoveryEmail => { - expect(recoveryEmail).to.equal('alice@example.com') - }) - }) - - it('should return undefined when agent mailto is missing', () => { - const userAccount = UserAccount.from({ username: 'alice' }) - - const emptyGraph = rdf.graph() - - const store = { - suffixAcl: '.acl', - getGraph: sinon.stub().resolves(emptyGraph) - } - - const options = { host, multiuser: true, store } - const accountManager = AccountManager.from(options) - - return accountManager.loadAccountRecoveryEmail(userAccount) - .then(recoveryEmail => { - expect(recoveryEmail).to.be.undefined() - }) - }) - }) - - describe('passwordResetUrl()', () => { - it('should return a token reset validation url', () => { - const tokenService = new TokenService() - const options = { host, multiuser: true, tokenService } - - const accountManager = AccountManager.from(options) - - const returnToUrl = 'https://example.com/resource' - const token = '123' - - const resetUrl = accountManager.passwordResetUrl(token, returnToUrl) - - const expectedUri = 'https://example.com/account/password/change?' + - 'token=123&returnToUrl=' + returnToUrl - - expect(resetUrl).to.equal(expectedUri) - }) - }) - - describe('generateDeleteToken()', () => { - it('should generate and store an expiring delete token', () => { - const tokenService = new TokenService() - const options = { host, tokenService } - - const accountManager = AccountManager.from(options) - - const aliceWebId = 'https://alice.example.com/#me' - const userAccount = { - webId: aliceWebId - } - - const token = accountManager.generateDeleteToken(userAccount) - - const tokenValue = accountManager.tokenService.verify('delete-account.mjs', token) - - expect(tokenValue.webId).to.equal(aliceWebId) - expect(tokenValue).to.have.property('exp') - }) - }) - - describe('generateResetToken()', () => { - it('should generate and store an expiring reset token', () => { - const tokenService = new TokenService() - const options = { host, tokenService } - - const accountManager = AccountManager.from(options) - - const aliceWebId = 'https://alice.example.com/#me' - const userAccount = { - webId: aliceWebId - } - - const token = accountManager.generateResetToken(userAccount) - - const tokenValue = accountManager.tokenService.verify('reset-password', token) - - expect(tokenValue.webId).to.equal(aliceWebId) - expect(tokenValue).to.have.property('exp') - }) - }) - - describe('sendPasswordResetEmail()', () => { - it('should compose and send a password reset email', () => { - const resetToken = '1234' - const tokenService = { - generate: sinon.stub().returns(resetToken) - } - - const emailService = { - sendWithTemplate: sinon.stub().resolves() - } - - const aliceWebId = 'https://alice.example.com/#me' - const userAccount = { - webId: aliceWebId, - email: 'alice@example.com' - } - const returnToUrl = 'https://example.com/resource' - - const options = { host, tokenService, emailService } - const accountManager = AccountManager.from(options) - - accountManager.passwordResetUrl = sinon.stub().returns('reset url') - - const expectedEmailData = { - to: 'alice@example.com', - webId: aliceWebId, - resetUrl: 'reset url' - } - - return accountManager.sendPasswordResetEmail(userAccount, returnToUrl) - .then(() => { - expect(accountManager.passwordResetUrl) - .to.have.been.calledWith(resetToken, returnToUrl) - expect(emailService.sendWithTemplate) - .to.have.been.calledWith('reset-password.mjs', expectedEmailData) - }) - }) - - it('should reject if no email service is set up', done => { - const aliceWebId = 'https://alice.example.com/#me' - const userAccount = { - webId: aliceWebId, - email: 'alice@example.com' - } - const returnToUrl = 'https://example.com/resource' - const options = { host } - const accountManager = AccountManager.from(options) - - accountManager.sendPasswordResetEmail(userAccount, returnToUrl) - .catch(error => { - expect(error.message).to.equal('Email service is not set up') - done() - }) - }) - - it('should reject if no user email is provided', done => { - const aliceWebId = 'https://alice.example.com/#me' - const userAccount = { - webId: aliceWebId - } - const returnToUrl = 'https://example.com/resource' - const emailService = {} - const options = { host, emailService } - - const accountManager = AccountManager.from(options) - - accountManager.sendPasswordResetEmail(userAccount, returnToUrl) - .catch(error => { - expect(error.message).to.equal('Account recovery email has not been provided') - done() - }) - }) - }) - - describe('sendDeleteAccountEmail()', () => { - it('should compose and send a delete account email', () => { - const deleteToken = '1234' - const tokenService = { - generate: sinon.stub().returns(deleteToken) - } - - const emailService = { - sendWithTemplate: sinon.stub().resolves() - } - - const aliceWebId = 'https://alice.example.com/#me' - const userAccount = { - webId: aliceWebId, - email: 'alice@example.com' - } - - const options = { host, tokenService, emailService } - const accountManager = AccountManager.from(options) - - accountManager.getAccountDeleteUrl = sinon.stub().returns('delete account url') - - const expectedEmailData = { - to: 'alice@example.com', - webId: aliceWebId, - deleteUrl: 'delete account url' - } - - return accountManager.sendDeleteAccountEmail(userAccount) - .then(() => { - expect(accountManager.getAccountDeleteUrl) - .to.have.been.calledWith(deleteToken) - expect(emailService.sendWithTemplate) - .to.have.been.calledWith('delete-account.mjs', expectedEmailData) - }) - }) - - it('should reject if no email service is set up', done => { - const aliceWebId = 'https://alice.example.com/#me' - const userAccount = { - webId: aliceWebId, - email: 'alice@example.com' - } - const options = { host } - const accountManager = AccountManager.from(options) - - accountManager.sendDeleteAccountEmail(userAccount) - .catch(error => { - expect(error.message).to.equal('Email service is not set up') - done() - }) - }) - - it('should reject if no user email is provided', done => { - const aliceWebId = 'https://alice.example.com/#me' - const userAccount = { - webId: aliceWebId - } - const emailService = {} - const options = { host, emailService } - - const accountManager = AccountManager.from(options) - - accountManager.sendDeleteAccountEmail(userAccount) - .catch(error => { - expect(error.message).to.equal('Account recovery email has not been provided') - done() - }) - }) - }) -}) +import { describe, it, beforeEach } from 'mocha' +import { fileURLToPath } from 'url' +import path from 'path' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' + +// Import CommonJS modules that haven't been converted yet +import rdf from 'rdflib' +import vocab from 'solid-namespace' + +// Import ESM modules (assuming they exist or will be created) +import LDP from '../../lib/ldp.js' +import SolidHost from '../../lib/models/solid-host.js' +import AccountManager from '../../lib/models/account-manager.js' +import UserAccount from '../../lib/models/user-account.js' +import TokenService from '../../lib/services/token-service.js' +import WebIdTlsCertificate from '../../lib/models/webid-tls-certificate.js' +import ResourceMapper from '../../lib/resource-mapper.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() +const ns = vocab(rdf) + +const testAccountsDir = path.join(__dirname, '../resources/accounts') + +let host + +beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) +}) + +describe('AccountManager', () => { + describe('from()', () => { + it('should init with passed in options', () => { + const config = { + host, + authMethod: 'oidc', + multiuser: true, + store: {}, + emailService: {}, + tokenService: {} + } + + const mgr = AccountManager.from(config) + expect(mgr.host).to.equal(config.host) + expect(mgr.authMethod).to.equal(config.authMethod) + expect(mgr.multiuser).to.equal(config.multiuser) + expect(mgr.store).to.equal(config.store) + expect(mgr.emailService).to.equal(config.emailService) + expect(mgr.tokenService).to.equal(config.tokenService) + }) + + it('should error if no host param is passed in', () => { + expect(() => { AccountManager.from() }) + .to.throw(/AccountManager requires a host instance/) + }) + }) + + describe('accountUriFor', () => { + it('should compose account uri for an account in multi user mode', () => { + const options = { + multiuser: true, + host: SolidHost.from({ serverUri: 'https://localhost' }) + } + const mgr = AccountManager.from(options) + + const webId = mgr.accountUriFor('alice') + expect(webId).to.equal('https://alice.localhost') + }) + + it('should compose account uri for an account in single user mode', () => { + const options = { + multiuser: false, + host: SolidHost.from({ serverUri: 'https://localhost' }) + } + const mgr = AccountManager.from(options) + + const webId = mgr.accountUriFor('alice') + expect(webId).to.equal('https://localhost') + }) + }) + + describe('accountWebIdFor()', () => { + it('should compose a web id uri for an account in multi user mode', () => { + const options = { + multiuser: true, + host: SolidHost.from({ serverUri: 'https://localhost' }) + } + const mgr = AccountManager.from(options) + const webId = mgr.accountWebIdFor('alice') + expect(webId).to.equal('https://alice.localhost/profile/card#me') + }) + + it('should compose a web id uri for an account in single user mode', () => { + const options = { + multiuser: false, + host: SolidHost.from({ serverUri: 'https://localhost' }) + } + const mgr = AccountManager.from(options) + const webId = mgr.accountWebIdFor('alice') + expect(webId).to.equal('https://localhost/profile/card#me') + }) + }) + + describe('accountDirFor()', () => { + it('should match the solid root dir config, in single user mode', () => { + const multiuser = false + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + includeHost: multiuser, + rootPath: testAccountsDir + }) + const store = new LDP({ multiuser, resourceMapper }) + const options = { multiuser, store, host } + const accountManager = AccountManager.from(options) + + const accountDir = accountManager.accountDirFor('alice') + expect(accountDir).to.equal(store.resourceMapper._rootPath) + }) + + it('should compose the account dir in multi user mode', () => { + const multiuser = true + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + includeHost: multiuser, + rootPath: testAccountsDir + }) + const store = new LDP({ multiuser, resourceMapper }) + const host = SolidHost.from({ serverUri: 'https://localhost' }) + const options = { multiuser, store, host } + const accountManager = AccountManager.from(options) + + const accountDir = accountManager.accountDirFor('alice') + const expectedPath = path.join(testAccountsDir, 'alice.localhost') + expect(path.normalize(accountDir)).to.equal(path.normalize(expectedPath)) + }) + }) + + describe('userAccountFrom()', () => { + describe('in multi user mode', () => { + const multiuser = true + let options, accountManager + + beforeEach(() => { + options = { host, multiuser } + accountManager = AccountManager.from(options) + }) + + it('should throw an error if no username is passed', () => { + expect(() => { + accountManager.userAccountFrom({}) + }).to.throw(/Username or web id is required/) + }) + + it('should init webId from param if no username is passed', () => { + const userData = { webId: 'https://example.com' } + const newAccount = accountManager.userAccountFrom(userData) + expect(newAccount.webId).to.equal(userData.webId) + }) + + it('should derive the local account id from username, for external webid', () => { + const userData = { + externalWebId: 'https://alice.external.com/profile#me', + username: 'user1' + } + + const newAccount = accountManager.userAccountFrom(userData) + + expect(newAccount.username).to.equal('user1') + expect(newAccount.webId).to.equal('https://alice.external.com/profile#me') + expect(newAccount.externalWebId).to.equal('https://alice.external.com/profile#me') + expect(newAccount.localAccountId).to.equal('user1.example.com/profile/card#me') + }) + + it('should use the external web id as username if no username given', () => { + const userData = { + externalWebId: 'https://alice.external.com/profile#me' + } + + const newAccount = accountManager.userAccountFrom(userData) + + expect(newAccount.username).to.equal('https://alice.external.com/profile#me') + expect(newAccount.webId).to.equal('https://alice.external.com/profile#me') + expect(newAccount.externalWebId).to.equal('https://alice.external.com/profile#me') + }) + }) + + describe('in single user mode', () => { + const multiuser = false + let options, accountManager + + beforeEach(() => { + options = { host, multiuser } + accountManager = AccountManager.from(options) + }) + + it('should not throw an error if no username is passed', () => { + expect(() => { + accountManager.userAccountFrom({}) + }).to.not.throw(Error) + }) + }) + }) + + describe('addCertKeyToProfile()', () => { + let accountManager, certificate, userAccount, profileGraph + + beforeEach(() => { + const options = { host } + accountManager = AccountManager.from(options) + userAccount = accountManager.userAccountFrom({ username: 'alice' }) + certificate = WebIdTlsCertificate.fromSpkacPost('1234', userAccount, host) + profileGraph = {} + }) + + it('should fetch the profile graph', () => { + accountManager.getProfileGraphFor = sinon.stub().returns(Promise.resolve()) + accountManager.addCertKeyToGraph = sinon.stub() + accountManager.saveProfileGraph = sinon.stub() + + return accountManager.addCertKeyToProfile(certificate, userAccount) + .then(() => { + expect(accountManager.getProfileGraphFor).to + .have.been.calledWith(userAccount) + }) + }) + + it('should add the cert key to the account graph', () => { + accountManager.getProfileGraphFor = sinon.stub() + .returns(Promise.resolve(profileGraph)) + accountManager.addCertKeyToGraph = sinon.stub() + accountManager.saveProfileGraph = sinon.stub() + + return accountManager.addCertKeyToProfile(certificate, userAccount) + .then(() => { + expect(accountManager.addCertKeyToGraph).to + .have.been.calledWith(certificate, profileGraph) + expect(accountManager.addCertKeyToGraph).to + .have.been.calledAfter(accountManager.getProfileGraphFor) + }) + }) + + it('should save the modified graph to the profile doc', () => { + accountManager.getProfileGraphFor = sinon.stub() + .returns(Promise.resolve(profileGraph)) + accountManager.addCertKeyToGraph = sinon.stub() + .returns(Promise.resolve(profileGraph)) + accountManager.saveProfileGraph = sinon.stub() + + return accountManager.addCertKeyToProfile(certificate, userAccount) + .then(() => { + expect(accountManager.saveProfileGraph).to + .have.been.calledWith(profileGraph, userAccount) + expect(accountManager.saveProfileGraph).to + .have.been.calledAfter(accountManager.addCertKeyToGraph) + }) + }) + }) + + describe('getProfileGraphFor()', () => { + it('should throw an error if webId is missing', (done) => { + const emptyUserData = {} + const userAccount = UserAccount.from(emptyUserData) + const options = { host, multiuser: true } + const accountManager = AccountManager.from(options) + + accountManager.getProfileGraphFor(userAccount) + .catch(error => { + expect(error.message).to + .equal('Cannot fetch profile graph, missing WebId URI') + done() + }) + }) + + it('should fetch the profile graph via LDP store', () => { + const store = { + getGraph: sinon.stub().returns(Promise.resolve()) + } + const webId = 'https://alice.example.com/#me' + const profileHostUri = 'https://alice.example.com/' + + const userData = { webId } + const userAccount = UserAccount.from(userData) + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + + expect(userAccount.webId).to.equal(webId) + + return accountManager.getProfileGraphFor(userAccount) + .then(() => { + expect(store.getGraph).to.have.been.calledWith(profileHostUri) + }) + }) + }) + + describe('saveProfileGraph()', () => { + it('should save the profile graph via the LDP store', () => { + const store = { + putGraph: sinon.stub().returns(Promise.resolve()) + } + const webId = 'https://alice.example.com/#me' + const profileHostUri = 'https://alice.example.com/' + + const userData = { webId } + const userAccount = UserAccount.from(userData) + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + const profileGraph = rdf.graph() + + return accountManager.saveProfileGraph(profileGraph, userAccount) + .then(() => { + expect(store.putGraph).to.have.been.calledWith(profileGraph, profileHostUri) + }) + }) + }) + + describe('rootAclFor()', () => { + it('should return the server root .acl in single user mode', () => { + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + rootPath: process.cwd(), + includeHost: false + }) + const store = new LDP({ suffixAcl: '.acl', multiuser: false, resourceMapper }) + const options = { host, multiuser: false, store } + const accountManager = AccountManager.from(options) + + const userAccount = UserAccount.from({ username: 'alice' }) + + const rootAclUri = accountManager.rootAclFor(userAccount) + + expect(rootAclUri).to.equal('https://example.com/.acl') + }) + + it('should return the profile root .acl in multi user mode', () => { + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + rootPath: process.cwd(), + includeHost: true + }) + const store = new LDP({ suffixAcl: '.acl', multiuser: true, resourceMapper }) + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + + const userAccount = UserAccount.from({ username: 'alice' }) + + const rootAclUri = accountManager.rootAclFor(userAccount) + + expect(rootAclUri).to.equal('https://alice.example.com/.acl') + }) + }) + + describe('loadAccountRecoveryEmail()', () => { + it('parses and returns the agent mailto from the root acl', () => { + const userAccount = UserAccount.from({ username: 'alice' }) + + const rootAclGraph = rdf.graph() + rootAclGraph.add( + rdf.namedNode('https://alice.example.com/.acl#owner'), + ns.acl('agent'), + rdf.namedNode('mailto:alice@example.com') + ) + + const store = { + suffixAcl: '.acl', + getGraph: sinon.stub().resolves(rootAclGraph) + } + + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + + return accountManager.loadAccountRecoveryEmail(userAccount) + .then(recoveryEmail => { + expect(recoveryEmail).to.equal('alice@example.com') + }) + }) + + it('should return undefined when agent mailto is missing', () => { + const userAccount = UserAccount.from({ username: 'alice' }) + + const emptyGraph = rdf.graph() + + const store = { + suffixAcl: '.acl', + getGraph: sinon.stub().resolves(emptyGraph) + } + + const options = { host, multiuser: true, store } + const accountManager = AccountManager.from(options) + + return accountManager.loadAccountRecoveryEmail(userAccount) + .then(recoveryEmail => { + expect(recoveryEmail).to.be.undefined() + }) + }) + }) + + describe('passwordResetUrl()', () => { + it('should return a token reset validation url', () => { + const tokenService = new TokenService() + const options = { host, multiuser: true, tokenService } + + const accountManager = AccountManager.from(options) + + const returnToUrl = 'https://example.com/resource' + const token = '123' + + const resetUrl = accountManager.passwordResetUrl(token, returnToUrl) + + const expectedUri = 'https://example.com/account/password/change?' + + 'token=123&returnToUrl=' + returnToUrl + + expect(resetUrl).to.equal(expectedUri) + }) + }) + + describe('generateDeleteToken()', () => { + it('should generate and store an expiring delete token', () => { + const tokenService = new TokenService() + const options = { host, tokenService } + + const accountManager = AccountManager.from(options) + + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId + } + + const token = accountManager.generateDeleteToken(userAccount) + + const tokenValue = accountManager.tokenService.verify('delete-account.js', token) + + expect(tokenValue.webId).to.equal(aliceWebId) + expect(tokenValue).to.have.property('exp') + }) + }) + + describe('generateResetToken()', () => { + it('should generate and store an expiring reset token', () => { + const tokenService = new TokenService() + const options = { host, tokenService } + + const accountManager = AccountManager.from(options) + + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId + } + + const token = accountManager.generateResetToken(userAccount) + + const tokenValue = accountManager.tokenService.verify('reset-password', token) + + expect(tokenValue.webId).to.equal(aliceWebId) + expect(tokenValue).to.have.property('exp') + }) + }) + + describe('sendPasswordResetEmail()', () => { + it('should compose and send a password reset email', () => { + const resetToken = '1234' + const tokenService = { + generate: sinon.stub().returns(resetToken) + } + + const emailService = { + sendWithTemplate: sinon.stub().resolves() + } + + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId, + email: 'alice@example.com' + } + const returnToUrl = 'https://example.com/resource' + + const options = { host, tokenService, emailService } + const accountManager = AccountManager.from(options) + + accountManager.passwordResetUrl = sinon.stub().returns('reset url') + + const expectedEmailData = { + to: 'alice@example.com', + webId: aliceWebId, + resetUrl: 'reset url' + } + + return accountManager.sendPasswordResetEmail(userAccount, returnToUrl) + .then(() => { + expect(accountManager.passwordResetUrl) + .to.have.been.calledWith(resetToken, returnToUrl) + expect(emailService.sendWithTemplate) + .to.have.been.calledWith('reset-password.js', expectedEmailData) + }) + }) + + it('should reject if no email service is set up', done => { + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId, + email: 'alice@example.com' + } + const returnToUrl = 'https://example.com/resource' + const options = { host } + const accountManager = AccountManager.from(options) + + accountManager.sendPasswordResetEmail(userAccount, returnToUrl) + .catch(error => { + expect(error.message).to.equal('Email service is not set up') + done() + }) + }) + + it('should reject if no user email is provided', done => { + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId + } + const returnToUrl = 'https://example.com/resource' + const emailService = {} + const options = { host, emailService } + + const accountManager = AccountManager.from(options) + + accountManager.sendPasswordResetEmail(userAccount, returnToUrl) + .catch(error => { + expect(error.message).to.equal('Account recovery email has not been provided') + done() + }) + }) + }) + + describe('sendDeleteAccountEmail()', () => { + it('should compose and send a delete account email', () => { + const deleteToken = '1234' + const tokenService = { + generate: sinon.stub().returns(deleteToken) + } + + const emailService = { + sendWithTemplate: sinon.stub().resolves() + } + + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId, + email: 'alice@example.com' + } + + const options = { host, tokenService, emailService } + const accountManager = AccountManager.from(options) + + accountManager.getAccountDeleteUrl = sinon.stub().returns('delete account url') + + const expectedEmailData = { + to: 'alice@example.com', + webId: aliceWebId, + deleteUrl: 'delete account url' + } + + return accountManager.sendDeleteAccountEmail(userAccount) + .then(() => { + expect(accountManager.getAccountDeleteUrl) + .to.have.been.calledWith(deleteToken) + expect(emailService.sendWithTemplate) + .to.have.been.calledWith('delete-account.js', expectedEmailData) + }) + }) + + it('should reject if no email service is set up', done => { + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId, + email: 'alice@example.com' + } + const options = { host } + const accountManager = AccountManager.from(options) + + accountManager.sendDeleteAccountEmail(userAccount) + .catch(error => { + expect(error.message).to.equal('Email service is not set up') + done() + }) + }) + + it('should reject if no user email is provided', done => { + const aliceWebId = 'https://alice.example.com/#me' + const userAccount = { + webId: aliceWebId + } + const emailService = {} + const options = { host, emailService } + + const accountManager = AccountManager.from(options) + + accountManager.sendDeleteAccountEmail(userAccount) + .catch(error => { + expect(error.message).to.equal('Account recovery email has not been provided') + done() + }) + }) + }) +}) diff --git a/test/unit/account-template-test.mjs b/test/unit/account-template-test.js similarity index 91% rename from test/unit/account-template-test.mjs rename to test/unit/account-template-test.js index d30a46cd2..15c2b2fad 100644 --- a/test/unit/account-template-test.mjs +++ b/test/unit/account-template-test.js @@ -1,58 +1,58 @@ -import chai from 'chai' -import sinonChai from 'sinon-chai' - -import AccountTemplate from '../../lib/models/account-template.mjs' -import UserAccount from '../../lib/models/user-account.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.should() - -describe('AccountTemplate', () => { - describe('isTemplate()', () => { - const template = new AccountTemplate() - - it('should recognize rdf files as templates', () => { - expect(template.isTemplate('./file.ttl')).to.be.true - expect(template.isTemplate('./file.rdf')).to.be.true - expect(template.isTemplate('./file.html')).to.be.true - expect(template.isTemplate('./file.jsonld')).to.be.true - }) - - it('should recognize files with template extensions as templates', () => { - expect(template.isTemplate('./.acl')).to.be.true - expect(template.isTemplate('./.meta')).to.be.true - expect(template.isTemplate('./file.json')).to.be.true - expect(template.isTemplate('./file.acl')).to.be.true - expect(template.isTemplate('./file.meta')).to.be.true - expect(template.isTemplate('./file.hbs')).to.be.true - expect(template.isTemplate('./file.handlebars')).to.be.true - }) - - it('should recognize reserved files with no extensions as templates', () => { - expect(template.isTemplate('./card')).to.be.true - }) - - it('should recognize arbitrary binary files as non-templates', () => { - expect(template.isTemplate('./favicon.ico')).to.be.false - expect(template.isTemplate('./file')).to.be.false - }) - }) - - describe('templateSubstitutionsFor()', () => { - it('should init', () => { - const userOptions = { - username: 'alice', - webId: 'https://alice.example.com/profile/card#me', - name: 'Alice Q.', - email: 'alice@example.com' - } - const userAccount = UserAccount.from(userOptions) - - const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) - expect(substitutions.name).to.equal('Alice Q.') - expect(substitutions.email).to.equal('alice@example.com') - expect(substitutions.webId).to.equal('/profile/card#me') - }) - }) -}) +import chai from 'chai' +import sinonChai from 'sinon-chai' + +import AccountTemplate from '../../lib/models/account-template.js' +import UserAccount from '../../lib/models/user-account.js' + +const { expect } = chai +chai.use(sinonChai) +chai.should() + +describe('AccountTemplate', () => { + describe('isTemplate()', () => { + const template = new AccountTemplate() + + it('should recognize rdf files as templates', () => { + expect(template.isTemplate('./file.ttl')).to.be.true + expect(template.isTemplate('./file.rdf')).to.be.true + expect(template.isTemplate('./file.html')).to.be.true + expect(template.isTemplate('./file.jsonld')).to.be.true + }) + + it('should recognize files with template extensions as templates', () => { + expect(template.isTemplate('./.acl')).to.be.true + expect(template.isTemplate('./.meta')).to.be.true + expect(template.isTemplate('./file.json')).to.be.true + expect(template.isTemplate('./file.acl')).to.be.true + expect(template.isTemplate('./file.meta')).to.be.true + expect(template.isTemplate('./file.hbs')).to.be.true + expect(template.isTemplate('./file.handlebars')).to.be.true + }) + + it('should recognize reserved files with no extensions as templates', () => { + expect(template.isTemplate('./card')).to.be.true + }) + + it('should recognize arbitrary binary files as non-templates', () => { + expect(template.isTemplate('./favicon.ico')).to.be.false + expect(template.isTemplate('./file')).to.be.false + }) + }) + + describe('templateSubstitutionsFor()', () => { + it('should init', () => { + const userOptions = { + username: 'alice', + webId: 'https://alice.example.com/profile/card#me', + name: 'Alice Q.', + email: 'alice@example.com' + } + const userAccount = UserAccount.from(userOptions) + + const substitutions = AccountTemplate.templateSubstitutionsFor(userAccount) + expect(substitutions.name).to.equal('Alice Q.') + expect(substitutions.email).to.equal('alice@example.com') + expect(substitutions.webId).to.equal('/profile/card#me') + }) + }) +}) diff --git a/test/unit/acl-checker-test.mjs b/test/unit/acl-checker-test.js similarity index 93% rename from test/unit/acl-checker-test.mjs rename to test/unit/acl-checker-test.js index 808776648..b1c3b2b4c 100644 --- a/test/unit/acl-checker-test.mjs +++ b/test/unit/acl-checker-test.js @@ -1,51 +1,51 @@ -import { describe, it } from 'mocha' -import chai from 'chai' -import chaiAsPromised from 'chai-as-promised' - -import ACLChecker from '../../lib/acl-checker.mjs' - -const { expect } = chai -chai.use(chaiAsPromised) - -const options = { fetch: (url, callback) => {} } - -describe('ACLChecker unit test', () => { - describe('getPossibleACLs', () => { - it('returns all possible ACLs of the root', () => { - const aclChecker = new ACLChecker('http://ex.org/', options) - expect(aclChecker.getPossibleACLs()).to.deep.equal([ - 'http://ex.org/.acl' - ]) - }) - - it('returns all possible ACLs of a regular file', () => { - const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi', options) - expect(aclChecker.getPossibleACLs()).to.deep.equal([ - 'http://ex.org/abc/def/ghi.acl', - 'http://ex.org/abc/def/.acl', - 'http://ex.org/abc/.acl', - 'http://ex.org/.acl' - ]) - }) - - it('returns all possible ACLs of an ACL file', () => { - const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi.acl', options) - expect(aclChecker.getPossibleACLs()).to.deep.equal([ - 'http://ex.org/abc/def/ghi.acl', - 'http://ex.org/abc/def/.acl', - 'http://ex.org/abc/.acl', - 'http://ex.org/.acl' - ]) - }) - - it('returns all possible ACLs of a directory', () => { - const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi/', options) - expect(aclChecker.getPossibleACLs()).to.deep.equal([ - 'http://ex.org/abc/def/ghi/.acl', - 'http://ex.org/abc/def/.acl', - 'http://ex.org/abc/.acl', - 'http://ex.org/.acl' - ]) - }) - }) -}) +import { describe, it } from 'mocha' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' + +import ACLChecker from '../../lib/acl-checker.js' + +const { expect } = chai +chai.use(chaiAsPromised) + +const options = { fetch: (url, callback) => {} } + +describe('ACLChecker unit test', () => { + describe('getPossibleACLs', () => { + it('returns all possible ACLs of the root', () => { + const aclChecker = new ACLChecker('http://ex.org/', options) + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/.acl' + ]) + }) + + it('returns all possible ACLs of a regular file', () => { + const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi', options) + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/abc/def/ghi.acl', + 'http://ex.org/abc/def/.acl', + 'http://ex.org/abc/.acl', + 'http://ex.org/.acl' + ]) + }) + + it('returns all possible ACLs of an ACL file', () => { + const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi.acl', options) + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/abc/def/ghi.acl', + 'http://ex.org/abc/def/.acl', + 'http://ex.org/abc/.acl', + 'http://ex.org/.acl' + ]) + }) + + it('returns all possible ACLs of a directory', () => { + const aclChecker = new ACLChecker('http://ex.org/abc/def/ghi/', options) + expect(aclChecker.getPossibleACLs()).to.deep.equal([ + 'http://ex.org/abc/def/ghi/.acl', + 'http://ex.org/abc/def/.acl', + 'http://ex.org/abc/.acl', + 'http://ex.org/.acl' + ]) + }) + }) +}) diff --git a/test/unit/add-cert-request-test.mjs b/test/unit/add-cert-request-test.js similarity index 93% rename from test/unit/add-cert-request-test.mjs rename to test/unit/add-cert-request-test.js index 5e71a529e..0269b75d2 100644 --- a/test/unit/add-cert-request-test.mjs +++ b/test/unit/add-cert-request-test.js @@ -1,119 +1,119 @@ -import { fileURLToPath } from 'url' -import fs from 'fs-extra' -import path from 'path' -import rdf from 'rdflib' -import solidNamespace from 'solid-namespace' -import chai from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' -import HttpMocks from 'node-mocks-http' - -import SolidHost from '../../lib/models/solid-host.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' -import AddCertificateRequest from '../../lib/requests/add-cert-request.mjs' -import WebIdTlsCertificate from '../../lib/models/webid-tls-certificate.mjs' - -const { expect } = chai -const ns = solidNamespace(rdf) -chai.use(sinonChai) -chai.should() - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const exampleSpkac = fs.readFileSync( - path.join(__dirname, '../resources/example_spkac.cnf'), 'utf8' -) - -let host - -beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) -}) - -describe('AddCertificateRequest', () => { - describe('fromParams()', () => { - it('should throw a 401 error if session.userId is missing', () => { - const multiuser = true - const options = { host, multiuser, authMethod: 'oidc' } - const accountManager = AccountManager.from(options) - - const req = { - body: { spkac: '123', webid: 'https://alice.example.com/#me' }, - session: {} - } - const res = HttpMocks.createResponse() - - try { - AddCertificateRequest.fromParams(req, res, accountManager) - } catch (error) { - expect(error.status).to.equal(401) - } - }) - }) - - describe('createRequest()', () => { - const multiuser = true - - it('should call certificate.generateCertificate()', () => { - const options = { host, multiuser, authMethod: 'oidc' } - const accountManager = AccountManager.from(options) - - const req = { - body: { spkac: '123', webid: 'https://alice.example.com/#me' }, - session: { - userId: 'https://alice.example.com/#me' - } - } - const res = HttpMocks.createResponse() - - const request = AddCertificateRequest.fromParams(req, res, accountManager) - const certificate = request.certificate - - accountManager.addCertKeyToProfile = sinon.stub() - request.sendResponse = sinon.stub() - const certSpy = sinon.stub(certificate, 'generateCertificate').returns(Promise.resolve()) - - return AddCertificateRequest.addCertificate(request) - .then(() => { - expect(certSpy).to.have.been.called - }) - }) - }) - - describe('accountManager.addCertKeyToGraph()', () => { - const multiuser = true - - it('should add certificate data to a graph', () => { - const options = { host, multiuser, authMethod: 'oidc' } - const accountManager = AccountManager.from(options) - - const userData = { username: 'alice' } - const userAccount = accountManager.userAccountFrom(userData) - - const certificate = WebIdTlsCertificate.fromSpkacPost( - decodeURIComponent(exampleSpkac), - userAccount, - host) - - const graph = rdf.graph() - - return certificate.generateCertificate() - .then(() => { - return accountManager.addCertKeyToGraph(certificate, graph) - }) - .then(graph => { - const webId = rdf.namedNode(certificate.webId) - const key = rdf.namedNode(certificate.keyUri) - - expect(graph.anyStatementMatching(webId, ns.cert('key'), key)) - .to.exist - expect(graph.anyStatementMatching(key, ns.rdf('type'), ns.cert('RSAPublicKey'))) - .to.exist - expect(graph.anyStatementMatching(key, ns.cert('modulus'))) - .to.exist - expect(graph.anyStatementMatching(key, ns.cert('exponent'))) - .to.exist - }) - }) - }) -}) +import { fileURLToPath } from 'url' +import fs from 'fs-extra' +import path from 'path' +import rdf from 'rdflib' +import solidNamespace from 'solid-namespace' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import HttpMocks from 'node-mocks-http' + +import SolidHost from '../../lib/models/solid-host.js' +import AccountManager from '../../lib/models/account-manager.js' +import AddCertificateRequest from '../../lib/requests/add-cert-request.js' +import WebIdTlsCertificate from '../../lib/models/webid-tls-certificate.js' + +const { expect } = chai +const ns = solidNamespace(rdf) +chai.use(sinonChai) +chai.should() + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const exampleSpkac = fs.readFileSync( + path.join(__dirname, '../resources/example_spkac.cnf'), 'utf8' +) + +let host + +beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) +}) + +describe('AddCertificateRequest', () => { + describe('fromParams()', () => { + it('should throw a 401 error if session.userId is missing', () => { + const multiuser = true + const options = { host, multiuser, authMethod: 'oidc' } + const accountManager = AccountManager.from(options) + + const req = { + body: { spkac: '123', webid: 'https://alice.example.com/#me' }, + session: {} + } + const res = HttpMocks.createResponse() + + try { + AddCertificateRequest.fromParams(req, res, accountManager) + } catch (error) { + expect(error.status).to.equal(401) + } + }) + }) + + describe('createRequest()', () => { + const multiuser = true + + it('should call certificate.generateCertificate()', () => { + const options = { host, multiuser, authMethod: 'oidc' } + const accountManager = AccountManager.from(options) + + const req = { + body: { spkac: '123', webid: 'https://alice.example.com/#me' }, + session: { + userId: 'https://alice.example.com/#me' + } + } + const res = HttpMocks.createResponse() + + const request = AddCertificateRequest.fromParams(req, res, accountManager) + const certificate = request.certificate + + accountManager.addCertKeyToProfile = sinon.stub() + request.sendResponse = sinon.stub() + const certSpy = sinon.stub(certificate, 'generateCertificate').returns(Promise.resolve()) + + return AddCertificateRequest.addCertificate(request) + .then(() => { + expect(certSpy).to.have.been.called + }) + }) + }) + + describe('accountManager.addCertKeyToGraph()', () => { + const multiuser = true + + it('should add certificate data to a graph', () => { + const options = { host, multiuser, authMethod: 'oidc' } + const accountManager = AccountManager.from(options) + + const userData = { username: 'alice' } + const userAccount = accountManager.userAccountFrom(userData) + + const certificate = WebIdTlsCertificate.fromSpkacPost( + decodeURIComponent(exampleSpkac), + userAccount, + host) + + const graph = rdf.graph() + + return certificate.generateCertificate() + .then(() => { + return accountManager.addCertKeyToGraph(certificate, graph) + }) + .then(graph => { + const webId = rdf.namedNode(certificate.webId) + const key = rdf.namedNode(certificate.keyUri) + + expect(graph.anyStatementMatching(webId, ns.cert('key'), key)) + .to.exist + expect(graph.anyStatementMatching(key, ns.rdf('type'), ns.cert('RSAPublicKey'))) + .to.exist + expect(graph.anyStatementMatching(key, ns.cert('modulus'))) + .to.exist + expect(graph.anyStatementMatching(key, ns.cert('exponent'))) + .to.exist + }) + }) + }) +}) diff --git a/test/unit/auth-handlers-test.mjs b/test/unit/auth-handlers-test.js similarity index 94% rename from test/unit/auth-handlers-test.mjs rename to test/unit/auth-handlers-test.js index 97c6c5ce1..9154ce3a4 100644 --- a/test/unit/auth-handlers-test.mjs +++ b/test/unit/auth-handlers-test.js @@ -1,108 +1,108 @@ -import { describe, it, beforeEach } from 'mocha' -import chai from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' -import dirtyChai from 'dirty-chai' - -// Import CommonJS modules -// const Auth = require('../../lib/api/authn') -import * as Auth from '../../lib/api/authn/index.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.use(dirtyChai) -chai.should() - -describe('OIDC Handler', () => { - describe('setAuthenticateHeader()', () => { - let res, req - - beforeEach(() => { - req = { - app: { - locals: { host: { serverUri: 'https://example.com' } } - }, - get: sinon.stub() - } - res = { set: sinon.stub() } - }) - - it('should set the WWW-Authenticate header with error params', () => { - const error = { - error: 'invalid_token', - error_description: 'Invalid token', - error_uri: 'https://example.com/errors/token' - } - - Auth.oidc.setAuthenticateHeader(req, res, error) - - expect(res.set).to.be.calledWith( - 'WWW-Authenticate', - 'Bearer realm="https://example.com", scope="openid webid", error="invalid_token", error_description="Invalid token", error_uri="https://example.com/errors/token"' - ) - }) - - it('should set WWW-Authenticate with no error_description if none given', () => { - const error = {} - - Auth.oidc.setAuthenticateHeader(req, res, error) - - expect(res.set).to.be.calledWith( - 'WWW-Authenticate', - 'Bearer realm="https://example.com", scope="openid webid"' - ) - }) - }) - - describe('isEmptyToken()', () => { - let req - - beforeEach(() => { - req = { get: sinon.stub() } - }) - - it('should be true for empty access token', () => { - req.get.withArgs('Authorization').returns('Bearer ') - - expect(Auth.oidc.isEmptyToken(req)).to.be.true() - - req.get.withArgs('Authorization').returns('Bearer') - - expect(Auth.oidc.isEmptyToken(req)).to.be.true() - }) - - it('should be false when access token is present', () => { - req.get.withArgs('Authorization').returns('Bearer token123') - - expect(Auth.oidc.isEmptyToken(req)).to.be.false() - }) - - it('should be false when no authorization header is present', () => { - expect(Auth.oidc.isEmptyToken(req)).to.be.false() - }) - }) -}) - -describe('WebID-TLS Handler', () => { - describe('setAuthenticateHeader()', () => { - let res, req - - beforeEach(() => { - req = { - app: { - locals: { host: { serverUri: 'https://example.com' } } - } - } - res = { set: sinon.stub() } - }) - - it('should set the WWW-Authenticate header', () => { - Auth.tls.setAuthenticateHeader(req, res) - - expect(res.set).to.be.calledWith( - 'WWW-Authenticate', - 'WebID-TLS realm="https://example.com"' - ) - }) - }) -}) +import { describe, it, beforeEach } from 'mocha' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' + +// Import CommonJS modules +// const Auth = require('../../lib/api/authn') +import * as Auth from '../../lib/api/authn/index.js' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() + +describe('OIDC Handler', () => { + describe('setAuthenticateHeader()', () => { + let res, req + + beforeEach(() => { + req = { + app: { + locals: { host: { serverUri: 'https://example.com' } } + }, + get: sinon.stub() + } + res = { set: sinon.stub() } + }) + + it('should set the WWW-Authenticate header with error params', () => { + const error = { + error: 'invalid_token', + error_description: 'Invalid token', + error_uri: 'https://example.com/errors/token' + } + + Auth.oidc.setAuthenticateHeader(req, res, error) + + expect(res.set).to.be.calledWith( + 'WWW-Authenticate', + 'Bearer realm="https://example.com", scope="openid webid", error="invalid_token", error_description="Invalid token", error_uri="https://example.com/errors/token"' + ) + }) + + it('should set WWW-Authenticate with no error_description if none given', () => { + const error = {} + + Auth.oidc.setAuthenticateHeader(req, res, error) + + expect(res.set).to.be.calledWith( + 'WWW-Authenticate', + 'Bearer realm="https://example.com", scope="openid webid"' + ) + }) + }) + + describe('isEmptyToken()', () => { + let req + + beforeEach(() => { + req = { get: sinon.stub() } + }) + + it('should be true for empty access token', () => { + req.get.withArgs('Authorization').returns('Bearer ') + + expect(Auth.oidc.isEmptyToken(req)).to.be.true() + + req.get.withArgs('Authorization').returns('Bearer') + + expect(Auth.oidc.isEmptyToken(req)).to.be.true() + }) + + it('should be false when access token is present', () => { + req.get.withArgs('Authorization').returns('Bearer token123') + + expect(Auth.oidc.isEmptyToken(req)).to.be.false() + }) + + it('should be false when no authorization header is present', () => { + expect(Auth.oidc.isEmptyToken(req)).to.be.false() + }) + }) +}) + +describe('WebID-TLS Handler', () => { + describe('setAuthenticateHeader()', () => { + let res, req + + beforeEach(() => { + req = { + app: { + locals: { host: { serverUri: 'https://example.com' } } + } + } + res = { set: sinon.stub() } + }) + + it('should set the WWW-Authenticate header', () => { + Auth.tls.setAuthenticateHeader(req, res) + + expect(res.set).to.be.calledWith( + 'WWW-Authenticate', + 'WebID-TLS realm="https://example.com"' + ) + }) + }) +}) diff --git a/test/unit/auth-proxy-test.mjs b/test/unit/auth-proxy-test.js similarity index 95% rename from test/unit/auth-proxy-test.mjs rename to test/unit/auth-proxy-test.js index 4ad852e2e..46d74e493 100644 --- a/test/unit/auth-proxy-test.mjs +++ b/test/unit/auth-proxy-test.js @@ -1,224 +1,224 @@ -import express from 'express' -import request from 'supertest' -import nock from 'nock' -import chai from 'chai' - -import authProxy from '../../lib/handlers/auth-proxy.mjs' - -const { expect } = chai - -const HOST = 'solid.org' -const USER = 'https://ruben.verborgh.org/profile/#me' - -describe('Auth Proxy', () => { - describe('An auth proxy with 2 destinations', () => { - let loggedIn = true - - let app - before(() => { - // Set up test back-end servers - nock('http://server-a.org').persist() - .get(/./).reply(200, addRequestDetails('a')) - nock('https://server-b.org').persist() - .get(/./).reply(200, addRequestDetails('b')) - - // Set up proxy server - app = express() - app.use((req, res, next) => { - if (loggedIn) { - req.session = { userId: USER } - } - next() - }) - authProxy(app, { - '/server/a': 'http://server-a.org', - '/server/b': 'https://server-b.org/foo/bar' - }) - }) - - after(() => { - // Release back-end servers - nock.cleanAll() - }) - - describe('responding to /server/a', () => { - let response - before(() => { - return request(app).get('/server/a') - .set('Host', HOST) - .then(res => { response = res }) - }) - - it('proxies to http://server-a.org/', () => { - const { server, path } = response.body - expect(server).to.equal('a') - expect(path).to.equal('/') - }) - - it('sets the User header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('user', USER) - }) - - it('sets the Host header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('host', 'server-a.org') - }) - - it('sets the Forwarded header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('forwarded', `host=${HOST}`) - }) - - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('responding to /server/a/my/path?query=string', () => { - let response - before(() => { - return request(app).get('/server/a/my/path?query=string') - .set('Host', HOST) - .then(res => { response = res }) - }) - - it('proxies to http://server-a.org/my/path?query=string', () => { - const { server, path } = response.body - expect(server).to.equal('a') - expect(path).to.equal('/my/path?query=string') - }) - - it('sets the User header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('user', USER) - }) - - it('sets the Host header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('host', 'server-a.org') - }) - - it('sets the Forwarded header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('forwarded', `host=${HOST}`) - }) - - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('responding to /server/b', () => { - let response - before(() => { - return request(app).get('/server/b') - .set('Host', HOST) - .then(res => { response = res }) - }) - - it('proxies to http://server-b.org/foo/bar', () => { - const { server, path } = response.body - expect(server).to.equal('b') - expect(path).to.equal('/foo/bar') - }) - - it('sets the User header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('user', USER) - }) - - it('sets the Host header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('host', 'server-b.org') - }) - - it('sets the Forwarded header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('forwarded', `host=${HOST}`) - }) - - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('responding to /server/b/my/path?query=string', () => { - let response - before(() => { - return request(app).get('/server/b/my/path?query=string') - .set('Host', HOST) - .then(res => { response = res }) - }) - - it('proxies to http://server-b.org/foo/bar/my/path?query=string', () => { - const { server, path } = response.body - expect(server).to.equal('b') - expect(path).to.equal('/foo/bar/my/path?query=string') - }) - - it('sets the User header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('user', USER) - }) - - it('sets the Host header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('host', 'server-b.org') - }) - - it('sets the Forwarded header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('forwarded', `host=${HOST}`) - }) - - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - - describe('responding to /server/a without a logged-in user', () => { - let response - before(() => { - loggedIn = false - return request(app).get('/server/a') - .set('Host', HOST) - .then(res => { response = res }) - }) - after(() => { - loggedIn = true - }) - - it('proxies to http://server-a.org/', () => { - const { server, path } = response.body - expect(server).to.equal('a') - expect(path).to.equal('/') - }) - - it('does not set the User header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.not.have.property('user') - }) - - it('sets the Host header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('host', 'server-a.org') - }) - - it('sets the Forwarded header on the proxy request', () => { - const { headers } = response.body - expect(headers).to.have.property('forwarded', `host=${HOST}`) - }) - - it('returns status code 200', () => { - expect(response.statusCode).to.equal(200) - }) - }) - }) -}) - -function addRequestDetails (server) { - return function (path) { - return { server, path, headers: this.req.headers } - } -} +import express from 'express' +import request from 'supertest' +import nock from 'nock' +import chai from 'chai' + +import authProxy from '../../lib/handlers/auth-proxy.js' + +const { expect } = chai + +const HOST = 'solid.org' +const USER = 'https://ruben.verborgh.org/profile/#me' + +describe('Auth Proxy', () => { + describe('An auth proxy with 2 destinations', () => { + let loggedIn = true + + let app + before(() => { + // Set up test back-end servers + nock('http://server-a.org').persist() + .get(/./).reply(200, addRequestDetails('a')) + nock('https://server-b.org').persist() + .get(/./).reply(200, addRequestDetails('b')) + + // Set up proxy server + app = express() + app.use((req, res, next) => { + if (loggedIn) { + req.session = { userId: USER } + } + next() + }) + authProxy(app, { + '/server/a': 'http://server-a.org', + '/server/b': 'https://server-b.org/foo/bar' + }) + }) + + after(() => { + // Release back-end servers + nock.cleanAll() + }) + + describe('responding to /server/a', () => { + let response + before(() => { + return request(app).get('/server/a') + .set('Host', HOST) + .then(res => { response = res }) + }) + + it('proxies to http://server-a.org/', () => { + const { server, path } = response.body + expect(server).to.equal('a') + expect(path).to.equal('/') + }) + + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-a.org') + }) + + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('responding to /server/a/my/path?query=string', () => { + let response + before(() => { + return request(app).get('/server/a/my/path?query=string') + .set('Host', HOST) + .then(res => { response = res }) + }) + + it('proxies to http://server-a.org/my/path?query=string', () => { + const { server, path } = response.body + expect(server).to.equal('a') + expect(path).to.equal('/my/path?query=string') + }) + + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-a.org') + }) + + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('responding to /server/b', () => { + let response + before(() => { + return request(app).get('/server/b') + .set('Host', HOST) + .then(res => { response = res }) + }) + + it('proxies to http://server-b.org/foo/bar', () => { + const { server, path } = response.body + expect(server).to.equal('b') + expect(path).to.equal('/foo/bar') + }) + + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-b.org') + }) + + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('responding to /server/b/my/path?query=string', () => { + let response + before(() => { + return request(app).get('/server/b/my/path?query=string') + .set('Host', HOST) + .then(res => { response = res }) + }) + + it('proxies to http://server-b.org/foo/bar/my/path?query=string', () => { + const { server, path } = response.body + expect(server).to.equal('b') + expect(path).to.equal('/foo/bar/my/path?query=string') + }) + + it('sets the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('user', USER) + }) + + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-b.org') + }) + + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + + describe('responding to /server/a without a logged-in user', () => { + let response + before(() => { + loggedIn = false + return request(app).get('/server/a') + .set('Host', HOST) + .then(res => { response = res }) + }) + after(() => { + loggedIn = true + }) + + it('proxies to http://server-a.org/', () => { + const { server, path } = response.body + expect(server).to.equal('a') + expect(path).to.equal('/') + }) + + it('does not set the User header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.not.have.property('user') + }) + + it('sets the Host header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('host', 'server-a.org') + }) + + it('sets the Forwarded header on the proxy request', () => { + const { headers } = response.body + expect(headers).to.have.property('forwarded', `host=${HOST}`) + }) + + it('returns status code 200', () => { + expect(response.statusCode).to.equal(200) + }) + }) + }) +}) + +function addRequestDetails (server) { + return function (path) { + return { server, path, headers: this.req.headers } + } +} diff --git a/test/unit/auth-request-test.mjs b/test/unit/auth-request-test.js similarity index 88% rename from test/unit/auth-request-test.mjs rename to test/unit/auth-request-test.js index 0659b5e6d..744534617 100644 --- a/test/unit/auth-request-test.mjs +++ b/test/unit/auth-request-test.js @@ -1,96 +1,96 @@ -import chai from 'chai' -import sinonChai from 'sinon-chai' -import dirtyChai from 'dirty-chai' - -import AuthRequest from '../../lib/requests/auth-request.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' -import UserAccount from '../../lib/models/user-account.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.use(dirtyChai) -chai.should() - -describe('AuthRequest', () => { - function testAuthQueryParams () { - const body = {} - body.response_type = 'code' - body.scope = 'openid' - body.client_id = 'client1' - body.redirect_uri = 'https://redirect.example.com/' - body.state = '1234' - body.nonce = '5678' - body.display = 'page' - - return body - } - - const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) - const accountManager = AccountManager.from({ host }) - - describe('extractAuthParams()', () => { - it('should initialize the auth url query object from params', () => { - const body = testAuthQueryParams() - body.other_key = 'whatever' - const req = { body, method: 'POST' } - - const extracted = AuthRequest.extractAuthParams(req) - - for (const param of AuthRequest.AUTH_QUERY_PARAMS) { - expect(extracted[param]).to.equal(body[param]) - } - - // make sure *only* the listed params were copied - expect(extracted.other_key).to.not.exist() - }) - - it('should return empty params with no request body present', () => { - const req = { method: 'POST' } - - expect(AuthRequest.extractAuthParams(req)).to.eql({}) - }) - }) - - describe('authorizeUrl()', () => { - it('should return an /authorize url', () => { - const request = new AuthRequest({ accountManager }) - - const authUrl = request.authorizeUrl() - - expect(authUrl.startsWith('https://localhost:8443/authorize')).to.be.true() - }) - - it('should pass through relevant auth query params from request body', () => { - const body = testAuthQueryParams() - const req = { body, method: 'POST' } - - const request = new AuthRequest({ accountManager }) - request.authQueryParams = AuthRequest.extractAuthParams(req) - - const authUrl = request.authorizeUrl() - - const parsedUrl = new URL(authUrl) - - for (const param in body) { - expect(body[param]).to.equal(parsedUrl.searchParams.get(param)) - } - }) - }) - - describe('initUserSession()', () => { - it('should initialize the request session', () => { - const webId = 'https://alice.example.com/#me' - const alice = UserAccount.from({ username: 'alice', webId }) - const session = {} - - const request = new AuthRequest({ session }) - - request.initUserSession(alice) - - expect(request.session.userId).to.equal(webId) - const subject = request.session.subject - expect(subject._id).to.equal(webId) - }) - }) -}) +import chai from 'chai' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' + +import AuthRequest from '../../lib/requests/auth-request.js' +import SolidHost from '../../lib/models/solid-host.js' +import AccountManager from '../../lib/models/account-manager.js' +import UserAccount from '../../lib/models/user-account.js' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() + +describe('AuthRequest', () => { + function testAuthQueryParams () { + const body = {} + body.response_type = 'code' + body.scope = 'openid' + body.client_id = 'client1' + body.redirect_uri = 'https://redirect.example.com/' + body.state = '1234' + body.nonce = '5678' + body.display = 'page' + + return body + } + + const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) + const accountManager = AccountManager.from({ host }) + + describe('extractAuthParams()', () => { + it('should initialize the auth url query object from params', () => { + const body = testAuthQueryParams() + body.other_key = 'whatever' + const req = { body, method: 'POST' } + + const extracted = AuthRequest.extractAuthParams(req) + + for (const param of AuthRequest.AUTH_QUERY_PARAMS) { + expect(extracted[param]).to.equal(body[param]) + } + + // make sure *only* the listed params were copied + expect(extracted.other_key).to.not.exist() + }) + + it('should return empty params with no request body present', () => { + const req = { method: 'POST' } + + expect(AuthRequest.extractAuthParams(req)).to.eql({}) + }) + }) + + describe('authorizeUrl()', () => { + it('should return an /authorize url', () => { + const request = new AuthRequest({ accountManager }) + + const authUrl = request.authorizeUrl() + + expect(authUrl.startsWith('https://localhost:8443/authorize')).to.be.true() + }) + + it('should pass through relevant auth query params from request body', () => { + const body = testAuthQueryParams() + const req = { body, method: 'POST' } + + const request = new AuthRequest({ accountManager }) + request.authQueryParams = AuthRequest.extractAuthParams(req) + + const authUrl = request.authorizeUrl() + + const parsedUrl = new URL(authUrl) + + for (const param in body) { + expect(body[param]).to.equal(parsedUrl.searchParams.get(param)) + } + }) + }) + + describe('initUserSession()', () => { + it('should initialize the request session', () => { + const webId = 'https://alice.example.com/#me' + const alice = UserAccount.from({ username: 'alice', webId }) + const session = {} + + const request = new AuthRequest({ session }) + + request.initUserSession(alice) + + expect(request.session.userId).to.equal(webId) + const subject = request.session.subject + expect(subject._id).to.equal(webId) + }) + }) +}) diff --git a/test/unit/authenticator-test.mjs b/test/unit/authenticator-test.js similarity index 89% rename from test/unit/authenticator-test.mjs rename to test/unit/authenticator-test.js index 6cc27f542..6703d6158 100644 --- a/test/unit/authenticator-test.mjs +++ b/test/unit/authenticator-test.js @@ -1,34 +1,34 @@ -import chai from 'chai' -import chaiAsPromised from 'chai-as-promised' -import { Authenticator } from '../../lib/models/authenticator.mjs' - -const { expect } = chai -chai.use(chaiAsPromised) -chai.should() - -describe('Authenticator', () => { - describe('constructor()', () => { - it('should initialize the accountManager property', () => { - const accountManager = {} - const auth = new Authenticator({ accountManager }) - - expect(auth.accountManager).to.equal(accountManager) - }) - }) - - describe('fromParams()', () => { - it('should throw an abstract method error', () => { - expect(() => Authenticator.fromParams()) - .to.throw(/Must override method/) - }) - }) - - describe('findValidUser()', () => { - it('should throw an abstract method error', () => { - const auth = new Authenticator({}) - - expect(() => auth.findValidUser()) - .to.throw(/Must override method/) - }) - }) -}) +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import { Authenticator } from '../../lib/models/authenticator.js' + +const { expect } = chai +chai.use(chaiAsPromised) +chai.should() + +describe('Authenticator', () => { + describe('constructor()', () => { + it('should initialize the accountManager property', () => { + const accountManager = {} + const auth = new Authenticator({ accountManager }) + + expect(auth.accountManager).to.equal(accountManager) + }) + }) + + describe('fromParams()', () => { + it('should throw an abstract method error', () => { + expect(() => Authenticator.fromParams()) + .to.throw(/Must override method/) + }) + }) + + describe('findValidUser()', () => { + it('should throw an abstract method error', () => { + const auth = new Authenticator({}) + + expect(() => auth.findValidUser()) + .to.throw(/Must override method/) + }) + }) +}) diff --git a/test/unit/blacklist-service-test.mjs b/test/unit/blacklist-service-test.js similarity index 96% rename from test/unit/blacklist-service-test.mjs rename to test/unit/blacklist-service-test.js index 934585d27..fa9a1a1f8 100644 --- a/test/unit/blacklist-service-test.mjs +++ b/test/unit/blacklist-service-test.js @@ -1,49 +1,49 @@ -import chai from 'chai' - -import theBigUsernameBlacklistPkg from 'the-big-username-blacklist' -import blacklistService from '../../lib/services/blacklist-service.mjs' - -const { expect } = chai -const blacklist = theBigUsernameBlacklistPkg.list - -describe('BlacklistService', () => { - afterEach(() => blacklistService.reset()) - - describe('addWord', () => { - it('allows adding words', () => { - const numberOfBlacklistedWords = blacklistService.list.length - blacklistService.addWord('foo') - expect(blacklistService.list.length).to.equal(numberOfBlacklistedWords + 1) - }) - }) - - describe('reset', () => { - it('will reset list of blacklisted words', () => { - blacklistService.addWord('foo') - blacklistService.reset() - expect(blacklistService.list.length).to.equal(blacklist.length) - }) - - it('can configure service via reset', () => { - blacklistService.reset({ - useTheBigUsernameBlacklist: false, - customBlacklistedUsernames: ['foo'] - }) - expect(blacklistService.list.length).to.equal(1) - expect(blacklistService.validate('admin')).to.equal(true) - }) - - it('is a singleton', () => { - const instanceA = blacklistService - blacklistService.reset({ customBlacklistedUsernames: ['foo'] }) - expect(instanceA.validate('foo')).to.equal(blacklistService.validate('foo')) - }) - }) - - describe('validate', () => { - it('validates given a default list of blacklisted usernames', () => { - const validWords = blacklist.reduce((memo, word) => memo + (blacklistService.validate(word) ? 1 : 0), 0) - expect(validWords).to.equal(0) - }) - }) +import chai from 'chai' + +import theBigUsernameBlacklistPkg from 'the-big-username-blacklist' +import blacklistService from '../../lib/services/blacklist-service.js' + +const { expect } = chai +const blacklist = theBigUsernameBlacklistPkg.list + +describe('BlacklistService', () => { + afterEach(() => blacklistService.reset()) + + describe('addWord', () => { + it('allows adding words', () => { + const numberOfBlacklistedWords = blacklistService.list.length + blacklistService.addWord('foo') + expect(blacklistService.list.length).to.equal(numberOfBlacklistedWords + 1) + }) + }) + + describe('reset', () => { + it('will reset list of blacklisted words', () => { + blacklistService.addWord('foo') + blacklistService.reset() + expect(blacklistService.list.length).to.equal(blacklist.length) + }) + + it('can configure service via reset', () => { + blacklistService.reset({ + useTheBigUsernameBlacklist: false, + customBlacklistedUsernames: ['foo'] + }) + expect(blacklistService.list.length).to.equal(1) + expect(blacklistService.validate('admin')).to.equal(true) + }) + + it('is a singleton', () => { + const instanceA = blacklistService + blacklistService.reset({ customBlacklistedUsernames: ['foo'] }) + expect(instanceA.validate('foo')).to.equal(blacklistService.validate('foo')) + }) + }) + + describe('validate', () => { + it('validates given a default list of blacklisted usernames', () => { + const validWords = blacklist.reduce((memo, word) => memo + (blacklistService.validate(word) ? 1 : 0), 0) + expect(validWords).to.equal(0) + }) + }) }) diff --git a/test/unit/create-account-request-test.mjs b/test/unit/create-account-request-test.js similarity index 94% rename from test/unit/create-account-request-test.mjs rename to test/unit/create-account-request-test.js index ba6a71e2a..190830484 100644 --- a/test/unit/create-account-request-test.mjs +++ b/test/unit/create-account-request-test.js @@ -1,306 +1,306 @@ -import chai from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' - -import HttpMocks from 'node-mocks-http' -import theBigUsernameBlacklistPkg from 'the-big-username-blacklist' - -import LDP from '../../lib/ldp.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' -import defaults from '../../config/defaults.mjs' -import { CreateAccountRequest } from '../../lib/requests/create-account-request.mjs' -import blacklistService from '../../lib/services/blacklist-service.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.should() -const blacklist = theBigUsernameBlacklistPkg - -describe('CreateAccountRequest', () => { - let host, store, accountManager - let session, res - - beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) - store = new LDP() - accountManager = AccountManager.from({ host, store }) - - session = {} - res = HttpMocks.createResponse() - }) - - describe('constructor()', () => { - it('should create an instance with the given config', () => { - const aliceData = { username: 'alice' } - const userAccount = accountManager.userAccountFrom(aliceData) - - const options = { accountManager, userAccount, session, response: res } - const request = new CreateAccountRequest(options) - - expect(request.accountManager).to.equal(accountManager) - expect(request.userAccount).to.equal(userAccount) - expect(request.session).to.equal(session) - expect(request.response).to.equal(res) - }) - }) - - describe('fromParams()', () => { - it('should create subclass depending on authMethod', () => { - let request, aliceData, req - - aliceData = { username: 'alice' } - req = HttpMocks.createRequest({ - app: { locals: { accountManager } }, body: aliceData, session - }) - req.app.locals.authMethod = 'tls' - - request = CreateAccountRequest.fromParams(req, res, accountManager) - expect(request).to.respondTo('generateTlsCertificate') - - aliceData = { username: 'alice', password: '12345' } - req = HttpMocks.createRequest({ - app: { locals: { accountManager, oidc: {} } }, body: aliceData, session - }) - req.app.locals.authMethod = 'oidc' - request = CreateAccountRequest.fromParams(req, res, accountManager) - expect(request).to.not.respondTo('generateTlsCertificate') - }) - }) - - describe('createAccount()', () => { - it('should return a 400 error if account already exists', done => { - const accountManager = AccountManager.from({ host }) - const locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } - const aliceData = { - username: 'alice', password: '1234' - } - const req = HttpMocks.createRequest({ app: { locals }, body: aliceData }) - - const request = CreateAccountRequest.fromParams(req, res) - - accountManager.accountExists = sinon.stub().returns(Promise.resolve(true)) - - request.createAccount() - .catch(err => { - expect(err.status).to.equal(400) - done() - }) - }) - - it('should return a 400 error if a username is invalid', () => { - const accountManager = AccountManager.from({ host }) - const locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } - - accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) - - const invalidUsernames = [ - '-', - '-a', - 'a-', - '9-', - 'alice--bob', - 'alice bob', - 'alice.bob' - ] - - let invalidUsernamesCount = 0 - - const requests = invalidUsernames.map((username) => { - const aliceData = { - username: username, password: '1234' - } - - const req = HttpMocks.createRequest({ app: { locals }, body: aliceData }) - const request = CreateAccountRequest.fromParams(req, res) - - return request.createAccount() - .then(() => { - throw new Error('should not happen') - }) - .catch(err => { - invalidUsernamesCount++ - expect(err.message).to.match(/Invalid username/) - expect(err.status).to.equal(400) - }) - }) - - return Promise.all(requests) - .then(() => { - expect(invalidUsernamesCount).to.eq(invalidUsernames.length) - }) - }) - - describe('Blacklisted usernames', () => { - const invalidUsernames = [...blacklist.list, 'foo'] - - before(() => { - const accountManager = AccountManager.from({ host }) - accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) - blacklistService.addWord('foo') - }) - - after(() => blacklistService.reset()) - - it('should return a 400 error if a username is blacklisted', async () => { - const locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } - - let invalidUsernamesCount = 0 - - const requests = invalidUsernames.map((username) => { - const req = HttpMocks.createRequest({ - app: { locals }, - body: { username, password: '1234' } - }) - const request = CreateAccountRequest.fromParams(req, res) - - return request.createAccount() - .then(() => { - throw new Error('should not happen') - }) - .catch(err => { - invalidUsernamesCount++ - expect(err.message).to.match(/Invalid username/) - expect(err.status).to.equal(400) - }) - }) - - await Promise.all(requests) - expect(invalidUsernamesCount).to.eq(invalidUsernames.length) - }) - }) - }) -}) - -describe('CreateOidcAccountRequest', () => { - const authMethod = 'oidc' - let host, store - let session, res - - beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) - store = new LDP() - session = {} - res = HttpMocks.createResponse() - }) - - describe('fromParams()', () => { - it('should create an instance with the given config', () => { - const accountManager = AccountManager.from({ host, store }) - const aliceData = { username: 'alice', password: '123' } - - const userStore = {} - const req = HttpMocks.createRequest({ - app: { - locals: { authMethod, oidc: { users: userStore }, accountManager } - }, - body: aliceData, - session - }) - - const request = CreateAccountRequest.fromParams(req, res) - - expect(request.accountManager).to.equal(accountManager) - expect(request.userAccount.username).to.equal('alice') - expect(request.session).to.equal(session) - expect(request.response).to.equal(res) - expect(request.password).to.equal(aliceData.password) - expect(request.userStore).to.equal(userStore) - }) - }) - - describe('saveCredentialsFor()', () => { - it('should create a new user in the user store', () => { - const accountManager = AccountManager.from({ host, store }) - const password = '12345' - const aliceData = { username: 'alice', password } - const userStore = { - createUser: (userAccount, password) => { return Promise.resolve() } - } - const createUserSpy = sinon.spy(userStore, 'createUser') - const req = HttpMocks.createRequest({ - app: { locals: { authMethod, oidc: { users: userStore }, accountManager } }, - body: aliceData, - session - }) - - const request = CreateAccountRequest.fromParams(req, res) - const userAccount = request.userAccount - - return request.saveCredentialsFor(userAccount) - .then(() => { - expect(createUserSpy).to.have.been.calledWith(userAccount, password) - }) - }) - }) - - describe('sendResponse()', () => { - it('should respond with a 302 Redirect', () => { - const accountManager = AccountManager.from({ host, store }) - const aliceData = { username: 'alice', password: '12345' } - const req = HttpMocks.createRequest({ - app: { locals: { authMethod, oidc: {}, accountManager } }, - body: aliceData, - session - }) - const alice = accountManager.userAccountFrom(aliceData) - - const request = CreateAccountRequest.fromParams(req, res) - - const result = request.sendResponse(alice) - expect(request.response.statusCode).to.equal(302) - expect(result.username).to.equal('alice') - }) - }) -}) - -describe('CreateTlsAccountRequest', () => { - const authMethod = 'tls' - let host, store - let session, res - - beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) - store = new LDP() - session = {} - res = HttpMocks.createResponse() - }) - - describe('fromParams()', () => { - it('should create an instance with the given config', () => { - const accountManager = AccountManager.from({ host, store }) - const aliceData = { username: 'alice' } - const req = HttpMocks.createRequest({ - app: { locals: { authMethod, accountManager } }, body: aliceData, session - }) - - const request = CreateAccountRequest.fromParams(req, res) - - expect(request.accountManager).to.equal(accountManager) - expect(request.userAccount.username).to.equal('alice') - expect(request.session).to.equal(session) - expect(request.response).to.equal(res) - expect(request.spkac).to.equal(aliceData.spkac) - }) - }) - - describe('saveCredentialsFor()', () => { - it('should call generateTlsCertificate()', () => { - const accountManager = AccountManager.from({ host, store }) - const aliceData = { username: 'alice' } - const req = HttpMocks.createRequest({ - app: { locals: { authMethod, accountManager } }, body: aliceData, session - }) - - const request = CreateAccountRequest.fromParams(req, res) - const userAccount = accountManager.userAccountFrom(aliceData) - - const generateTlsCertificate = sinon.spy(request, 'generateTlsCertificate') - - return request.saveCredentialsFor(userAccount) - .then(() => { - expect(generateTlsCertificate).to.have.been.calledWith(userAccount) - }) - }) - }) +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import HttpMocks from 'node-mocks-http' +import theBigUsernameBlacklistPkg from 'the-big-username-blacklist' + +import LDP from '../../lib/ldp.js' +import AccountManager from '../../lib/models/account-manager.js' +import SolidHost from '../../lib/models/solid-host.js' +import defaults from '../../config/defaults.js' +import { CreateAccountRequest } from '../../lib/requests/create-account-request.js' +import blacklistService from '../../lib/services/blacklist-service.js' + +const { expect } = chai +chai.use(sinonChai) +chai.should() +const blacklist = theBigUsernameBlacklistPkg + +describe('CreateAccountRequest', () => { + let host, store, accountManager + let session, res + + beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + store = new LDP() + accountManager = AccountManager.from({ host, store }) + + session = {} + res = HttpMocks.createResponse() + }) + + describe('constructor()', () => { + it('should create an instance with the given config', () => { + const aliceData = { username: 'alice' } + const userAccount = accountManager.userAccountFrom(aliceData) + + const options = { accountManager, userAccount, session, response: res } + const request = new CreateAccountRequest(options) + + expect(request.accountManager).to.equal(accountManager) + expect(request.userAccount).to.equal(userAccount) + expect(request.session).to.equal(session) + expect(request.response).to.equal(res) + }) + }) + + describe('fromParams()', () => { + it('should create subclass depending on authMethod', () => { + let request, aliceData, req + + aliceData = { username: 'alice' } + req = HttpMocks.createRequest({ + app: { locals: { accountManager } }, body: aliceData, session + }) + req.app.locals.authMethod = 'tls' + + request = CreateAccountRequest.fromParams(req, res, accountManager) + expect(request).to.respondTo('generateTlsCertificate') + + aliceData = { username: 'alice', password: '12345' } + req = HttpMocks.createRequest({ + app: { locals: { accountManager, oidc: {} } }, body: aliceData, session + }) + req.app.locals.authMethod = 'oidc' + request = CreateAccountRequest.fromParams(req, res, accountManager) + expect(request).to.not.respondTo('generateTlsCertificate') + }) + }) + + describe('createAccount()', () => { + it('should return a 400 error if account already exists', done => { + const accountManager = AccountManager.from({ host }) + const locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } + const aliceData = { + username: 'alice', password: '1234' + } + const req = HttpMocks.createRequest({ app: { locals }, body: aliceData }) + + const request = CreateAccountRequest.fromParams(req, res) + + accountManager.accountExists = sinon.stub().returns(Promise.resolve(true)) + + request.createAccount() + .catch(err => { + expect(err.status).to.equal(400) + done() + }) + }) + + it('should return a 400 error if a username is invalid', () => { + const accountManager = AccountManager.from({ host }) + const locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } + + accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) + + const invalidUsernames = [ + '-', + '-a', + 'a-', + '9-', + 'alice--bob', + 'alice bob', + 'alice.bob' + ] + + let invalidUsernamesCount = 0 + + const requests = invalidUsernames.map((username) => { + const aliceData = { + username: username, password: '1234' + } + + const req = HttpMocks.createRequest({ app: { locals }, body: aliceData }) + const request = CreateAccountRequest.fromParams(req, res) + + return request.createAccount() + .then(() => { + throw new Error('should not happen') + }) + .catch(err => { + invalidUsernamesCount++ + expect(err.message).to.match(/Invalid username/) + expect(err.status).to.equal(400) + }) + }) + + return Promise.all(requests) + .then(() => { + expect(invalidUsernamesCount).to.eq(invalidUsernames.length) + }) + }) + + describe('Blacklisted usernames', () => { + const invalidUsernames = [...blacklist.list, 'foo'] + + before(() => { + const accountManager = AccountManager.from({ host }) + accountManager.accountExists = sinon.stub().returns(Promise.resolve(false)) + blacklistService.addWord('foo') + }) + + after(() => blacklistService.reset()) + + it('should return a 400 error if a username is blacklisted', async () => { + const locals = { authMethod: defaults.auth, accountManager, oidc: { users: {} } } + + let invalidUsernamesCount = 0 + + const requests = invalidUsernames.map((username) => { + const req = HttpMocks.createRequest({ + app: { locals }, + body: { username, password: '1234' } + }) + const request = CreateAccountRequest.fromParams(req, res) + + return request.createAccount() + .then(() => { + throw new Error('should not happen') + }) + .catch(err => { + invalidUsernamesCount++ + expect(err.message).to.match(/Invalid username/) + expect(err.status).to.equal(400) + }) + }) + + await Promise.all(requests) + expect(invalidUsernamesCount).to.eq(invalidUsernames.length) + }) + }) + }) +}) + +describe('CreateOidcAccountRequest', () => { + const authMethod = 'oidc' + let host, store + let session, res + + beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + store = new LDP() + session = {} + res = HttpMocks.createResponse() + }) + + describe('fromParams()', () => { + it('should create an instance with the given config', () => { + const accountManager = AccountManager.from({ host, store }) + const aliceData = { username: 'alice', password: '123' } + + const userStore = {} + const req = HttpMocks.createRequest({ + app: { + locals: { authMethod, oidc: { users: userStore }, accountManager } + }, + body: aliceData, + session + }) + + const request = CreateAccountRequest.fromParams(req, res) + + expect(request.accountManager).to.equal(accountManager) + expect(request.userAccount.username).to.equal('alice') + expect(request.session).to.equal(session) + expect(request.response).to.equal(res) + expect(request.password).to.equal(aliceData.password) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('saveCredentialsFor()', () => { + it('should create a new user in the user store', () => { + const accountManager = AccountManager.from({ host, store }) + const password = '12345' + const aliceData = { username: 'alice', password } + const userStore = { + createUser: (userAccount, password) => { return Promise.resolve() } + } + const createUserSpy = sinon.spy(userStore, 'createUser') + const req = HttpMocks.createRequest({ + app: { locals: { authMethod, oidc: { users: userStore }, accountManager } }, + body: aliceData, + session + }) + + const request = CreateAccountRequest.fromParams(req, res) + const userAccount = request.userAccount + + return request.saveCredentialsFor(userAccount) + .then(() => { + expect(createUserSpy).to.have.been.calledWith(userAccount, password) + }) + }) + }) + + describe('sendResponse()', () => { + it('should respond with a 302 Redirect', () => { + const accountManager = AccountManager.from({ host, store }) + const aliceData = { username: 'alice', password: '12345' } + const req = HttpMocks.createRequest({ + app: { locals: { authMethod, oidc: {}, accountManager } }, + body: aliceData, + session + }) + const alice = accountManager.userAccountFrom(aliceData) + + const request = CreateAccountRequest.fromParams(req, res) + + const result = request.sendResponse(alice) + expect(request.response.statusCode).to.equal(302) + expect(result.username).to.equal('alice') + }) + }) +}) + +describe('CreateTlsAccountRequest', () => { + const authMethod = 'tls' + let host, store + let session, res + + beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + store = new LDP() + session = {} + res = HttpMocks.createResponse() + }) + + describe('fromParams()', () => { + it('should create an instance with the given config', () => { + const accountManager = AccountManager.from({ host, store }) + const aliceData = { username: 'alice' } + const req = HttpMocks.createRequest({ + app: { locals: { authMethod, accountManager } }, body: aliceData, session + }) + + const request = CreateAccountRequest.fromParams(req, res) + + expect(request.accountManager).to.equal(accountManager) + expect(request.userAccount.username).to.equal('alice') + expect(request.session).to.equal(session) + expect(request.response).to.equal(res) + expect(request.spkac).to.equal(aliceData.spkac) + }) + }) + + describe('saveCredentialsFor()', () => { + it('should call generateTlsCertificate()', () => { + const accountManager = AccountManager.from({ host, store }) + const aliceData = { username: 'alice' } + const req = HttpMocks.createRequest({ + app: { locals: { authMethod, accountManager } }, body: aliceData, session + }) + + const request = CreateAccountRequest.fromParams(req, res) + const userAccount = accountManager.userAccountFrom(aliceData) + + const generateTlsCertificate = sinon.spy(request, 'generateTlsCertificate') + + return request.saveCredentialsFor(userAccount) + .then(() => { + expect(generateTlsCertificate).to.have.been.calledWith(userAccount) + }) + }) + }) }) diff --git a/test/unit/delete-account-confirm-request-test.mjs b/test/unit/delete-account-confirm-request-test.js similarity index 95% rename from test/unit/delete-account-confirm-request-test.mjs rename to test/unit/delete-account-confirm-request-test.js index 5878f27bc..f9d59d697 100644 --- a/test/unit/delete-account-confirm-request-test.mjs +++ b/test/unit/delete-account-confirm-request-test.js @@ -1,234 +1,234 @@ -// import { createRequire } from 'module' -import chai from 'chai' -import sinon from 'sinon' -import dirtyChai from 'dirty-chai' -import sinonChai from 'sinon-chai' -import HttpMocks from 'node-mocks-http' - -// const require = createRequire(import.meta.url) -// const DeleteAccountConfirmRequest = require('../../lib/requests/delete-account-confirm-request') -// const SolidHost = require('../../lib/models/solid-host') -import DeleteAccountConfirmRequest from '../../lib/requests/delete-account-confirm-request.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' - -const { expect } = chai -chai.use(dirtyChai) -chai.use(sinonChai) -chai.should() - -describe('DeleteAccountConfirmRequest', () => { - sinon.spy(DeleteAccountConfirmRequest.prototype, 'error') - - describe('constructor()', () => { - it('should initialize a request instance from options', () => { - const res = HttpMocks.createResponse() - - const accountManager = {} - const userStore = {} - - const options = { - accountManager, - userStore, - response: res, - token: '12345' - } - - const request = new DeleteAccountConfirmRequest(options) - - expect(request.response).to.equal(res) - expect(request.token).to.equal(options.token) - expect(request.accountManager).to.equal(accountManager) - expect(request.userStore).to.equal(userStore) - }) - }) - - describe('fromParams()', () => { - it('should return a request instance from options', () => { - const token = '12345' - const accountManager = {} - const userStore = {} - - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { token } - } - const res = HttpMocks.createResponse() - - const request = DeleteAccountConfirmRequest.fromParams(req, res) - - expect(request.response).to.equal(res) - expect(request.token).to.equal(token) - expect(request.accountManager).to.equal(accountManager) - expect(request.userStore).to.equal(userStore) - }) - }) - - describe('get()', () => { - const token = '12345' - const userStore = {} - const res = HttpMocks.createResponse() - sinon.spy(res, 'render') - - it('should create an instance and render a delete account form', () => { - const accountManager = { - validateDeleteToken: sinon.stub().resolves(true) - } - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { token } - } - - return DeleteAccountConfirmRequest.get(req, res) - .then(() => { - expect(accountManager.validateDeleteToken) - .to.have.been.called() - expect(res.render).to.have.been.calledWith('account/delete-confirm', - { token, validToken: true }) - }) - }) - - it('should display an error message on an invalid token', () => { - const accountManager = { - validateDeleteToken: sinon.stub().throws() - } - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { token } - } - - return DeleteAccountConfirmRequest.get(req, res) - .then(() => { - expect(DeleteAccountConfirmRequest.prototype.error) - .to.have.been.called() - }) - }) - }) - - describe('post()', () => { - it('creates a request instance and invokes handlePost()', () => { - sinon.spy(DeleteAccountConfirmRequest, 'handlePost') - - const token = '12345' - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const alice = { - webId: 'https://alice.example.com/#me' - } - const storedToken = { webId: alice.webId } - const accountManager = { - host, - userAccountFrom: sinon.stub().resolves(alice), - validateDeleteToken: sinon.stub().resolves(storedToken) - } - - accountManager.accountExists = sinon.stub().resolves(true) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - - const req = { - app: { locals: { accountManager, oidc: { users: {} } } }, - body: { token } - } - const res = HttpMocks.createResponse() - - return DeleteAccountConfirmRequest.post(req, res) - .then(() => { - expect(DeleteAccountConfirmRequest.handlePost).to.have.been.called() - }) - }) - }) - - describe('handlePost()', () => { - it('should display error message if validation error encountered', () => { - const token = '12345' - const userStore = {} - const res = HttpMocks.createResponse() - const accountManager = { - validateResetToken: sinon.stub().throws() - } - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { token } - } - - const request = DeleteAccountConfirmRequest.fromParams(req, res) - - return DeleteAccountConfirmRequest.handlePost(request) - .then(() => { - expect(DeleteAccountConfirmRequest.prototype.error) - .to.have.been.called() - }) - }) - }) - - describe('validateToken()', () => { - it('should return false if no token is present', () => { - const accountManager = { - validateDeleteToken: sinon.stub() - } - const request = new DeleteAccountConfirmRequest({ accountManager, token: null }) - - return request.validateToken() - .then(result => { - expect(result).to.be.false() - expect(accountManager.validateDeleteToken).to.not.have.been.called() - }) - }) - }) - - describe('error()', () => { - it('should invoke renderForm() with the error', () => { - const request = new DeleteAccountConfirmRequest({}) - request.renderForm = sinon.stub() - const error = new Error('error message') - - request.error(error) - - expect(request.renderForm).to.have.been.calledWith(error) - }) - }) - - describe('deleteAccount()', () => { - it('should remove user from userStore and remove directories', () => { - const webId = 'https://alice.example.com/#me' - const user = { webId, id: webId } - const accountManager = { - userAccountFrom: sinon.stub().returns(user), - accountDirFor: sinon.stub().returns('/some/path/to/data/for/alice.example.com/') - } - const userStore = { - deleteUser: sinon.stub().resolves() - } - - const options = { - accountManager, userStore, newPassword: 'swordfish' - } - const request = new DeleteAccountConfirmRequest(options) - const tokenContents = { webId } - - return request.deleteAccount(tokenContents) - .then(() => { - expect(accountManager.userAccountFrom).to.have.been.calledWith(tokenContents) - expect(accountManager.accountDirFor).to.have.been.calledWith(user.username) - expect(userStore.deleteUser).to.have.been.calledWith(user) - }) - }) - }) - - describe('renderForm()', () => { - it('should set response status to error status, if error exists', () => { - const token = '12345' - const response = HttpMocks.createResponse() - sinon.spy(response, 'render') - - const options = { token, response } - - const request = new DeleteAccountConfirmRequest(options) - - const error = new Error('error message') - - request.renderForm(error) - - expect(response.render).to.have.been.calledWith('account/delete-confirm', - { validToken: false, token, error: 'error message' }) - }) - }) +// import { createRequire } from 'module' +import chai from 'chai' +import sinon from 'sinon' +import dirtyChai from 'dirty-chai' +import sinonChai from 'sinon-chai' +import HttpMocks from 'node-mocks-http' + +// const require = createRequire(import.meta.url) +// const DeleteAccountConfirmRequest = require('../../lib/requests/delete-account-confirm-request') +// const SolidHost = require('../../lib/models/solid-host') +import DeleteAccountConfirmRequest from '../../lib/requests/delete-account-confirm-request.js' +import SolidHost from '../../lib/models/solid-host.js' + +const { expect } = chai +chai.use(dirtyChai) +chai.use(sinonChai) +chai.should() + +describe('DeleteAccountConfirmRequest', () => { + sinon.spy(DeleteAccountConfirmRequest.prototype, 'error') + + describe('constructor()', () => { + it('should initialize a request instance from options', () => { + const res = HttpMocks.createResponse() + + const accountManager = {} + const userStore = {} + + const options = { + accountManager, + userStore, + response: res, + token: '12345' + } + + const request = new DeleteAccountConfirmRequest(options) + + expect(request.response).to.equal(res) + expect(request.token).to.equal(options.token) + expect(request.accountManager).to.equal(accountManager) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('fromParams()', () => { + it('should return a request instance from options', () => { + const token = '12345' + const accountManager = {} + const userStore = {} + + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { token } + } + const res = HttpMocks.createResponse() + + const request = DeleteAccountConfirmRequest.fromParams(req, res) + + expect(request.response).to.equal(res) + expect(request.token).to.equal(token) + expect(request.accountManager).to.equal(accountManager) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('get()', () => { + const token = '12345' + const userStore = {} + const res = HttpMocks.createResponse() + sinon.spy(res, 'render') + + it('should create an instance and render a delete account form', () => { + const accountManager = { + validateDeleteToken: sinon.stub().resolves(true) + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { token } + } + + return DeleteAccountConfirmRequest.get(req, res) + .then(() => { + expect(accountManager.validateDeleteToken) + .to.have.been.called() + expect(res.render).to.have.been.calledWith('account/delete-confirm', + { token, validToken: true }) + }) + }) + + it('should display an error message on an invalid token', () => { + const accountManager = { + validateDeleteToken: sinon.stub().throws() + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { token } + } + + return DeleteAccountConfirmRequest.get(req, res) + .then(() => { + expect(DeleteAccountConfirmRequest.prototype.error) + .to.have.been.called() + }) + }) + }) + + describe('post()', () => { + it('creates a request instance and invokes handlePost()', () => { + sinon.spy(DeleteAccountConfirmRequest, 'handlePost') + + const token = '12345' + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const alice = { + webId: 'https://alice.example.com/#me' + } + const storedToken = { webId: alice.webId } + const accountManager = { + host, + userAccountFrom: sinon.stub().resolves(alice), + validateDeleteToken: sinon.stub().resolves(storedToken) + } + + accountManager.accountExists = sinon.stub().resolves(true) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + + const req = { + app: { locals: { accountManager, oidc: { users: {} } } }, + body: { token } + } + const res = HttpMocks.createResponse() + + return DeleteAccountConfirmRequest.post(req, res) + .then(() => { + expect(DeleteAccountConfirmRequest.handlePost).to.have.been.called() + }) + }) + }) + + describe('handlePost()', () => { + it('should display error message if validation error encountered', () => { + const token = '12345' + const userStore = {} + const res = HttpMocks.createResponse() + const accountManager = { + validateResetToken: sinon.stub().throws() + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { token } + } + + const request = DeleteAccountConfirmRequest.fromParams(req, res) + + return DeleteAccountConfirmRequest.handlePost(request) + .then(() => { + expect(DeleteAccountConfirmRequest.prototype.error) + .to.have.been.called() + }) + }) + }) + + describe('validateToken()', () => { + it('should return false if no token is present', () => { + const accountManager = { + validateDeleteToken: sinon.stub() + } + const request = new DeleteAccountConfirmRequest({ accountManager, token: null }) + + return request.validateToken() + .then(result => { + expect(result).to.be.false() + expect(accountManager.validateDeleteToken).to.not.have.been.called() + }) + }) + }) + + describe('error()', () => { + it('should invoke renderForm() with the error', () => { + const request = new DeleteAccountConfirmRequest({}) + request.renderForm = sinon.stub() + const error = new Error('error message') + + request.error(error) + + expect(request.renderForm).to.have.been.calledWith(error) + }) + }) + + describe('deleteAccount()', () => { + it('should remove user from userStore and remove directories', () => { + const webId = 'https://alice.example.com/#me' + const user = { webId, id: webId } + const accountManager = { + userAccountFrom: sinon.stub().returns(user), + accountDirFor: sinon.stub().returns('/some/path/to/data/for/alice.example.com/') + } + const userStore = { + deleteUser: sinon.stub().resolves() + } + + const options = { + accountManager, userStore, newPassword: 'swordfish' + } + const request = new DeleteAccountConfirmRequest(options) + const tokenContents = { webId } + + return request.deleteAccount(tokenContents) + .then(() => { + expect(accountManager.userAccountFrom).to.have.been.calledWith(tokenContents) + expect(accountManager.accountDirFor).to.have.been.calledWith(user.username) + expect(userStore.deleteUser).to.have.been.calledWith(user) + }) + }) + }) + + describe('renderForm()', () => { + it('should set response status to error status, if error exists', () => { + const token = '12345' + const response = HttpMocks.createResponse() + sinon.spy(response, 'render') + + const options = { token, response } + + const request = new DeleteAccountConfirmRequest(options) + + const error = new Error('error message') + + request.renderForm(error) + + expect(response.render).to.have.been.calledWith('account/delete-confirm', + { validToken: false, token, error: 'error message' }) + }) + }) }) diff --git a/test/unit/delete-account-request-test.mjs b/test/unit/delete-account-request-test.js similarity index 94% rename from test/unit/delete-account-request-test.mjs rename to test/unit/delete-account-request-test.js index 5b244a888..3a78f924b 100644 --- a/test/unit/delete-account-request-test.mjs +++ b/test/unit/delete-account-request-test.js @@ -1,180 +1,180 @@ -import chai from 'chai' -import sinon from 'sinon' -import dirtyChai from 'dirty-chai' -import sinonChai from 'sinon-chai' - -import HttpMocks from 'node-mocks-http' - -import DeleteAccountRequest from '../../lib/requests/delete-account-request.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' - -const { expect } = chai -chai.use(dirtyChai) -chai.use(sinonChai) -chai.should() - -describe('DeleteAccountRequest', () => { - describe('constructor()', () => { - it('should initialize a request instance from options', () => { - const res = HttpMocks.createResponse() - - const options = { - response: res, - username: 'alice' - } - - const request = new DeleteAccountRequest(options) - - expect(request.response).to.equal(res) - expect(request.username).to.equal(options.username) - }) - }) - - describe('fromParams()', () => { - it('should return a request instance from options', () => { - const username = 'alice' - const accountManager = {} - - const req = { - app: { locals: { accountManager } }, - body: { username } - } - const res = HttpMocks.createResponse() - - const request = DeleteAccountRequest.fromParams(req, res) - - expect(request.accountManager).to.equal(accountManager) - expect(request.username).to.equal(username) - expect(request.response).to.equal(res) - }) - }) - - describe('get()', () => { - it('should create an instance and render a delete account form', () => { - const username = 'alice' - const accountManager = { multiuser: true } - - const req = { - app: { locals: { accountManager } }, - body: { username } - } - const res = HttpMocks.createResponse() - res.render = sinon.stub() - - DeleteAccountRequest.get(req, res) - - expect(res.render).to.have.been.calledWith('account/delete', - { error: undefined, multiuser: true }) - }) - }) - - describe('post()', () => { - it('creates a request instance and invokes handlePost()', () => { - sinon.spy(DeleteAccountRequest, 'handlePost') - - const username = 'alice' - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { - suffixAcl: '.acl' - } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(true) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendDeleteLink = sinon.stub().resolves() - - const req = { - app: { locals: { accountManager } }, - body: { username } - } - const res = HttpMocks.createResponse() - - DeleteAccountRequest.post(req, res) - .then(() => { - expect(DeleteAccountRequest.handlePost).to.have.been.called() - }) - }) - }) - - describe('validate()', () => { - it('should throw an error if username is missing in multi-user mode', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const accountManager = AccountManager.from({ host, multiuser: true }) - - const request = new DeleteAccountRequest({ accountManager }) - - expect(() => request.validate()).to.throw(/Username required/) - }) - - it('should not throw an error if username is missing in single user mode', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const accountManager = AccountManager.from({ host, multiuser: false }) - - const request = new DeleteAccountRequest({ accountManager }) - - expect(() => request.validate()).to.not.throw() - }) - }) - - describe('handlePost()', () => { - it('should handle the post request', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { suffixAcl: '.acl' } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendDeleteAccountEmail = sinon.stub().resolves() - accountManager.accountExists = sinon.stub().resolves(true) - - const username = 'alice' - const response = HttpMocks.createResponse() - response.render = sinon.stub() - - const options = { accountManager, username, response } - const request = new DeleteAccountRequest(options) - - sinon.spy(request, 'error') - - return DeleteAccountRequest.handlePost(request) - .then(() => { - expect(accountManager.loadAccountRecoveryEmail).to.have.been.called() - expect(response.render).to.have.been.calledWith('account/delete-link-sent') - expect(request.error).to.not.have.been.called() - }) - }) - }) - - describe('loadUser()', () => { - it('should return a UserAccount instance based on username', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { suffixAcl: '.acl' } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(true) - const username = 'alice' - - const options = { accountManager, username } - const request = new DeleteAccountRequest(options) - - return request.loadUser() - .then(account => { - expect(account.webId).to.equal('https://alice.example.com/profile/card#me') - }) - }) - - it('should throw an error if the user does not exist', done => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { suffixAcl: '.acl' } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(false) - const username = 'alice' - - const options = { accountManager, username } - const request = new DeleteAccountRequest(options) - - request.loadUser() - .catch(error => { - expect(error.message).to.equal('Account not found for that username') - done() - }) - }) - }) +import chai from 'chai' +import sinon from 'sinon' +import dirtyChai from 'dirty-chai' +import sinonChai from 'sinon-chai' + +import HttpMocks from 'node-mocks-http' + +import DeleteAccountRequest from '../../lib/requests/delete-account-request.js' +import AccountManager from '../../lib/models/account-manager.js' +import SolidHost from '../../lib/models/solid-host.js' + +const { expect } = chai +chai.use(dirtyChai) +chai.use(sinonChai) +chai.should() + +describe('DeleteAccountRequest', () => { + describe('constructor()', () => { + it('should initialize a request instance from options', () => { + const res = HttpMocks.createResponse() + + const options = { + response: res, + username: 'alice' + } + + const request = new DeleteAccountRequest(options) + + expect(request.response).to.equal(res) + expect(request.username).to.equal(options.username) + }) + }) + + describe('fromParams()', () => { + it('should return a request instance from options', () => { + const username = 'alice' + const accountManager = {} + + const req = { + app: { locals: { accountManager } }, + body: { username } + } + const res = HttpMocks.createResponse() + + const request = DeleteAccountRequest.fromParams(req, res) + + expect(request.accountManager).to.equal(accountManager) + expect(request.username).to.equal(username) + expect(request.response).to.equal(res) + }) + }) + + describe('get()', () => { + it('should create an instance and render a delete account form', () => { + const username = 'alice' + const accountManager = { multiuser: true } + + const req = { + app: { locals: { accountManager } }, + body: { username } + } + const res = HttpMocks.createResponse() + res.render = sinon.stub() + + DeleteAccountRequest.get(req, res) + + expect(res.render).to.have.been.calledWith('account/delete', + { error: undefined, multiuser: true }) + }) + }) + + describe('post()', () => { + it('creates a request instance and invokes handlePost()', () => { + sinon.spy(DeleteAccountRequest, 'handlePost') + + const username = 'alice' + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { + suffixAcl: '.acl' + } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.accountExists = sinon.stub().resolves(true) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendDeleteLink = sinon.stub().resolves() + + const req = { + app: { locals: { accountManager } }, + body: { username } + } + const res = HttpMocks.createResponse() + + DeleteAccountRequest.post(req, res) + .then(() => { + expect(DeleteAccountRequest.handlePost).to.have.been.called() + }) + }) + }) + + describe('validate()', () => { + it('should throw an error if username is missing in multi-user mode', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const accountManager = AccountManager.from({ host, multiuser: true }) + + const request = new DeleteAccountRequest({ accountManager }) + + expect(() => request.validate()).to.throw(/Username required/) + }) + + it('should not throw an error if username is missing in single user mode', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const accountManager = AccountManager.from({ host, multiuser: false }) + + const request = new DeleteAccountRequest({ accountManager }) + + expect(() => request.validate()).to.not.throw() + }) + }) + + describe('handlePost()', () => { + it('should handle the post request', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendDeleteAccountEmail = sinon.stub().resolves() + accountManager.accountExists = sinon.stub().resolves(true) + + const username = 'alice' + const response = HttpMocks.createResponse() + response.render = sinon.stub() + + const options = { accountManager, username, response } + const request = new DeleteAccountRequest(options) + + sinon.spy(request, 'error') + + return DeleteAccountRequest.handlePost(request) + .then(() => { + expect(accountManager.loadAccountRecoveryEmail).to.have.been.called() + expect(response.render).to.have.been.calledWith('account/delete-link-sent') + expect(request.error).to.not.have.been.called() + }) + }) + }) + + describe('loadUser()', () => { + it('should return a UserAccount instance based on username', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.accountExists = sinon.stub().resolves(true) + const username = 'alice' + + const options = { accountManager, username } + const request = new DeleteAccountRequest(options) + + return request.loadUser() + .then(account => { + expect(account.webId).to.equal('https://alice.example.com/profile/card#me') + }) + }) + + it('should throw an error if the user does not exist', done => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.accountExists = sinon.stub().resolves(false) + const username = 'alice' + + const options = { accountManager, username } + const request = new DeleteAccountRequest(options) + + request.loadUser() + .catch(error => { + expect(error.message).to.equal('Account not found for that username') + done() + }) + }) + }) }) diff --git a/test/unit/email-service-test.mjs b/test/unit/email-service-test.js similarity index 96% rename from test/unit/email-service-test.mjs rename to test/unit/email-service-test.js index b244908e6..76de78847 100644 --- a/test/unit/email-service-test.mjs +++ b/test/unit/email-service-test.js @@ -1,165 +1,165 @@ -import sinon from 'sinon' -import chai from 'chai' -import sinonChai from 'sinon-chai' -import { fileURLToPath } from 'url' -import { dirname, join } from 'path' -import EmailService from '../../lib/services/email-service.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.should() - -// const require = createRequire(import.meta.url) -const __dirname = dirname(fileURLToPath(import.meta.url)) - -const templatePath = join(__dirname, '../../default-templates/emails') - -describe('Email Service', function () { - describe('EmailService constructor', () => { - it('should set up a nodemailer instance', () => { - const templatePath = '../../config/email-templates' - const config = { - host: 'smtp.gmail.com', - auth: { - user: 'alice@gmail.com', - pass: '12345' - } - } - - const emailService = new EmailService(templatePath, config) - expect(emailService.mailer.options.host).to.equal('smtp.gmail.com') - expect(emailService.mailer).to.respondTo('sendMail') - - expect(emailService.templatePath).to.equal(templatePath) - }) - - it('should init a sender address if explicitly passed in', () => { - const sender = 'Solid Server ' - const config = { host: 'smtp.gmail.com', auth: {}, sender } - - const emailService = new EmailService(templatePath, config) - expect(emailService.sender).to.equal(sender) - }) - - it('should construct a default sender if not passed in', () => { - const config = { host: 'databox.me', auth: {} } - - const emailService = new EmailService(templatePath, config) - - expect(emailService.sender).to.equal('no-reply@databox.me') - }) - }) - - describe('sendMail()', () => { - it('passes through the sendMail call to the initialized mailer', () => { - const sendMail = sinon.stub().returns(Promise.resolve()) - const config = { host: 'databox.me', auth: {} } - const emailService = new EmailService(templatePath, config) - - emailService.mailer.sendMail = sendMail - - const email = { subject: 'Test' } - - return emailService.sendMail(email) - .then(() => { - expect(sendMail).to.have.been.calledWith(email) - }) - }) - - it('uses the provided from:, if present', () => { - const config = { host: 'databox.me', auth: {} } - const emailService = new EmailService(templatePath, config) - const email = { subject: 'Test', from: 'alice@example.com' } - - emailService.mailer.sendMail = (email) => { return Promise.resolve(email) } - - return emailService.sendMail(email) - .then(email => { - expect(email.from).to.equal('alice@example.com') - }) - }) - - it('uses the default sender if a from: is not provided', () => { - const config = { host: 'databox.me', auth: {}, sender: 'solid@example.com' } - const emailService = new EmailService(templatePath, config) - const email = { subject: 'Test', from: null } - - emailService.mailer.sendMail = (email) => { return Promise.resolve(email) } - - return emailService.sendMail(email) - .then(email => { - expect(email.from).to.equal(config.sender) - }) - }) - }) - - describe('templatePathFor()', () => { - it('should compose filename based on base path and template name', () => { - const config = { host: 'databox.me', auth: {} } - const templatePath = '../../config/email-templates' - const emailService = new EmailService(templatePath, config) - - const templateFile = emailService.templatePathFor('welcome') - - expect(templateFile.endsWith('email-templates/welcome')) - }) - }) - - describe('readTemplate()', () => { - it('should read a template if it exists', async () => { - const config = { host: 'databox.me', auth: {} } - const emailService = new EmailService(templatePath, config) - - const template = await emailService.readTemplate('welcome.js') // support legacy name - - expect(template).to.respondTo('render') - }) - - it('should throw an error if a template does not exist', async () => { - const config = { host: 'databox.me', auth: {} } - const emailService = new EmailService(templatePath, config) - - try { - await emailService.readTemplate('invalid-template') - throw new Error('Expected readTemplate to throw') - } catch (err) { - expect(err.message).to.match(/Cannot find email template/) - } - }) - }) - - describe('sendWithTemplate()', () => { - it('should reject with error if template does not exist', done => { - const config = { host: 'databox.me', auth: {} } - const emailService = new EmailService(templatePath, config) - - const data = {} - - emailService.sendWithTemplate('invalid-template', data) - .catch(error => { - expect(error.message.startsWith('Cannot find email template')) - .to.be.true - done() - }) - }) - - it('should render an email from template and send it', () => { - const config = { host: 'databox.me', auth: {} } - const emailService = new EmailService(templatePath, config) - - emailService.sendMail = (email) => { return Promise.resolve(email) } - emailService.sendMail = sinon.spy(emailService, 'sendMail') - - const data = { webid: 'https://alice.example.com#me' } - - return emailService.sendWithTemplate('welcome.js', data) - .then(renderedEmail => { - expect(emailService.sendMail).to.be.called - - expect(renderedEmail.subject).to.exist - expect(renderedEmail.text.endsWith('Your Web Id: https://alice.example.com#me')) - .to.be.true - }) - }) - }) -}) +import sinon from 'sinon' +import chai from 'chai' +import sinonChai from 'sinon-chai' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import EmailService from '../../lib/services/email-service.js' + +const { expect } = chai +chai.use(sinonChai) +chai.should() + +// const require = createRequire(import.meta.url) +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const templatePath = join(__dirname, '../../default-templates/emails') + +describe('Email Service', function () { + describe('EmailService constructor', () => { + it('should set up a nodemailer instance', () => { + const templatePath = '../../config/email-templates' + const config = { + host: 'smtp.gmail.com', + auth: { + user: 'alice@gmail.com', + pass: '12345' + } + } + + const emailService = new EmailService(templatePath, config) + expect(emailService.mailer.options.host).to.equal('smtp.gmail.com') + expect(emailService.mailer).to.respondTo('sendMail') + + expect(emailService.templatePath).to.equal(templatePath) + }) + + it('should init a sender address if explicitly passed in', () => { + const sender = 'Solid Server ' + const config = { host: 'smtp.gmail.com', auth: {}, sender } + + const emailService = new EmailService(templatePath, config) + expect(emailService.sender).to.equal(sender) + }) + + it('should construct a default sender if not passed in', () => { + const config = { host: 'databox.me', auth: {} } + + const emailService = new EmailService(templatePath, config) + + expect(emailService.sender).to.equal('no-reply@databox.me') + }) + }) + + describe('sendMail()', () => { + it('passes through the sendMail call to the initialized mailer', () => { + const sendMail = sinon.stub().returns(Promise.resolve()) + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + + emailService.mailer.sendMail = sendMail + + const email = { subject: 'Test' } + + return emailService.sendMail(email) + .then(() => { + expect(sendMail).to.have.been.calledWith(email) + }) + }) + + it('uses the provided from:, if present', () => { + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + const email = { subject: 'Test', from: 'alice@example.com' } + + emailService.mailer.sendMail = (email) => { return Promise.resolve(email) } + + return emailService.sendMail(email) + .then(email => { + expect(email.from).to.equal('alice@example.com') + }) + }) + + it('uses the default sender if a from: is not provided', () => { + const config = { host: 'databox.me', auth: {}, sender: 'solid@example.com' } + const emailService = new EmailService(templatePath, config) + const email = { subject: 'Test', from: null } + + emailService.mailer.sendMail = (email) => { return Promise.resolve(email) } + + return emailService.sendMail(email) + .then(email => { + expect(email.from).to.equal(config.sender) + }) + }) + }) + + describe('templatePathFor()', () => { + it('should compose filename based on base path and template name', () => { + const config = { host: 'databox.me', auth: {} } + const templatePath = '../../config/email-templates' + const emailService = new EmailService(templatePath, config) + + const templateFile = emailService.templatePathFor('welcome') + + expect(templateFile.endsWith('email-templates/welcome')) + }) + }) + + describe('readTemplate()', () => { + it('should read a template if it exists', async () => { + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + + const template = await emailService.readTemplate('welcome.js') // support legacy name + + expect(template).to.respondTo('render') + }) + + it('should throw an error if a template does not exist', async () => { + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + + try { + await emailService.readTemplate('invalid-template') + throw new Error('Expected readTemplate to throw') + } catch (err) { + expect(err.message).to.match(/Cannot find email template/) + } + }) + }) + + describe('sendWithTemplate()', () => { + it('should reject with error if template does not exist', done => { + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + + const data = {} + + emailService.sendWithTemplate('invalid-template', data) + .catch(error => { + expect(error.message.startsWith('Cannot find email template')) + .to.be.true + done() + }) + }) + + it('should render an email from template and send it', () => { + const config = { host: 'databox.me', auth: {} } + const emailService = new EmailService(templatePath, config) + + emailService.sendMail = (email) => { return Promise.resolve(email) } + emailService.sendMail = sinon.spy(emailService, 'sendMail') + + const data = { webid: 'https://alice.example.com#me' } + + return emailService.sendWithTemplate('welcome.js', data) + .then(renderedEmail => { + expect(emailService.sendMail).to.be.called + + expect(renderedEmail.subject).to.exist + expect(renderedEmail.text.endsWith('Your Web Id: https://alice.example.com#me')) + .to.be.true + }) + }) + }) +}) diff --git a/test/unit/email-welcome-test.mjs b/test/unit/email-welcome-test.js similarity index 86% rename from test/unit/email-welcome-test.mjs rename to test/unit/email-welcome-test.js index bb150b40c..5d106dcd4 100644 --- a/test/unit/email-welcome-test.mjs +++ b/test/unit/email-welcome-test.js @@ -1,80 +1,80 @@ -import { fileURLToPath } from 'url' -import path from 'path' -import chai from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' - -import SolidHost from '../../lib/models/solid-host.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' -import EmailService from '../../lib/services/email-service.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.should() - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const templatePath = path.join(__dirname, '../../default-templates/emails') - -let host, accountManager, emailService - -beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) - - const emailConfig = { auth: {}, sender: 'solid@example.com' } - emailService = new EmailService(templatePath, emailConfig) - - const mgrConfig = { - host, - emailService, - authMethod: 'oidc', - multiuser: true - } - accountManager = AccountManager.from(mgrConfig) -}) - -describe('Account Creation Welcome Email', () => { - describe('accountManager.sendWelcomeEmail() (unit tests)', () => { - it('should resolve to null if email service not set up', () => { - accountManager.emailService = null - - const userData = { name: 'Alice', username: 'alice', email: 'alice@alice.com' } - const newUser = accountManager.userAccountFrom(userData) - - return accountManager.sendWelcomeEmail(newUser) - .then(result => { - expect(result).to.be.null - }) - }) - - it('should resolve to null if a new user has no email', () => { - const userData = { name: 'Alice', username: 'alice' } - const newUser = accountManager.userAccountFrom(userData) - - return accountManager.sendWelcomeEmail(newUser) - .then(result => { - expect(result).to.be.null - }) - }) - - it('should send an email using the welcome template', () => { - const sendWithTemplate = sinon - .stub(accountManager.emailService, 'sendWithTemplate') - .returns(Promise.resolve()) - - const userData = { name: 'Alice', username: 'alice', email: 'alice@alice.com' } - const newUser = accountManager.userAccountFrom(userData) - - const expectedEmailData = { - webid: 'https://alice.example.com/profile/card#me', - to: 'alice@alice.com', - name: 'Alice' - } - - return accountManager.sendWelcomeEmail(newUser) - .then(result => { - expect(sendWithTemplate).to.be.calledWith('welcome.mjs', expectedEmailData) - }) - }) - }) -}) +import { fileURLToPath } from 'url' +import path from 'path' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import SolidHost from '../../lib/models/solid-host.js' +import AccountManager from '../../lib/models/account-manager.js' +import EmailService from '../../lib/services/email-service.js' + +const { expect } = chai +chai.use(sinonChai) +chai.should() + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const templatePath = path.join(__dirname, '../../default-templates/emails') + +let host, accountManager, emailService + +beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) + + const emailConfig = { auth: {}, sender: 'solid@example.com' } + emailService = new EmailService(templatePath, emailConfig) + + const mgrConfig = { + host, + emailService, + authMethod: 'oidc', + multiuser: true + } + accountManager = AccountManager.from(mgrConfig) +}) + +describe('Account Creation Welcome Email', () => { + describe('accountManager.sendWelcomeEmail() (unit tests)', () => { + it('should resolve to null if email service not set up', () => { + accountManager.emailService = null + + const userData = { name: 'Alice', username: 'alice', email: 'alice@alice.com' } + const newUser = accountManager.userAccountFrom(userData) + + return accountManager.sendWelcomeEmail(newUser) + .then(result => { + expect(result).to.be.null + }) + }) + + it('should resolve to null if a new user has no email', () => { + const userData = { name: 'Alice', username: 'alice' } + const newUser = accountManager.userAccountFrom(userData) + + return accountManager.sendWelcomeEmail(newUser) + .then(result => { + expect(result).to.be.null + }) + }) + + it('should send an email using the welcome template', () => { + const sendWithTemplate = sinon + .stub(accountManager.emailService, 'sendWithTemplate') + .returns(Promise.resolve()) + + const userData = { name: 'Alice', username: 'alice', email: 'alice@alice.com' } + const newUser = accountManager.userAccountFrom(userData) + + const expectedEmailData = { + webid: 'https://alice.example.com/profile/card#me', + to: 'alice@alice.com', + name: 'Alice' + } + + return accountManager.sendWelcomeEmail(newUser) + .then(result => { + expect(sendWithTemplate).to.be.calledWith('welcome.js', expectedEmailData) + }) + }) + }) +}) diff --git a/test/unit/error-pages-test.mjs b/test/unit/error-pages-test.js similarity index 94% rename from test/unit/error-pages-test.mjs rename to test/unit/error-pages-test.js index 4aa199202..bd0f9e6d7 100644 --- a/test/unit/error-pages-test.mjs +++ b/test/unit/error-pages-test.js @@ -1,100 +1,100 @@ -import { describe, it } from 'mocha' -import chai from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' -import dirtyChai from 'dirty-chai' -import * as errorPages from '../../lib/handlers/error-pages.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.use(dirtyChai) -chai.should() - -describe('handlers/error-pages', () => { - describe('handler()', () => { - it('should use the custom error handler if available', () => { - const ldp = { errorHandler: sinon.stub() } - const req = { app: { locals: { ldp } } } - const res = { status: sinon.stub(), send: sinon.stub() } - const err = {} - const next = {} - - errorPages.handler(err, req, res, next) - - expect(ldp.errorHandler).to.have.been.calledWith(err, req, res, next) - - expect(res.status).to.not.have.been.called() - expect(res.send).to.not.have.been.called() - }) - - it('defaults to status code 500 if none is specified in the error', () => { - const ldp = { noErrorPages: true } - const req = { app: { locals: { ldp } } } - const res = { status: sinon.stub(), send: sinon.stub(), header: sinon.stub() } - const err = { message: 'Unspecified error' } - const next = {} - - errorPages.handler(err, req, res, next) - - expect(res.status).to.have.been.calledWith(500) - expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') - expect(res.send).to.have.been.calledWith('Unspecified error\n') - }) - }) - - describe('sendErrorResponse()', () => { - it('should send http status code and error message', () => { - const statusCode = 404 - const error = { - message: 'Error description' - } - const res = { - status: sinon.stub(), - header: sinon.stub(), - send: sinon.stub() - } - - errorPages.sendErrorResponse(statusCode, res, error) - - expect(res.status).to.have.been.calledWith(404) - expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') - expect(res.send).to.have.been.calledWith('Error description\n') - }) - }) - - describe('setAuthenticateHeader()', () => { - it('should do nothing for a non-implemented auth method', () => { - const err = {} - const req = { - app: { locals: { authMethod: null } } - } - const res = { - set: sinon.stub() - } - - errorPages.setAuthenticateHeader(req, res, err) - - expect(res.set).to.not.have.been.called() - }) - }) - - describe('sendErrorPage()', () => { - it('falls back the default sendErrorResponse if no page is found', () => { - const statusCode = 400 - const res = { - status: sinon.stub(), - header: sinon.stub(), - send: sinon.stub() - } - const err = { message: 'Error description' } - const ldp = { errorPages: './' } - - return errorPages.sendErrorPage(statusCode, res, err, ldp) - .then(() => { - expect(res.status).to.have.been.calledWith(400) - expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') - expect(res.send).to.have.been.calledWith('Error description\n') - }) - }) - }) -}) +import { describe, it } from 'mocha' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' +import * as errorPages from '../../lib/handlers/error-pages.js' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() + +describe('handlers/error-pages', () => { + describe('handler()', () => { + it('should use the custom error handler if available', () => { + const ldp = { errorHandler: sinon.stub() } + const req = { app: { locals: { ldp } } } + const res = { status: sinon.stub(), send: sinon.stub() } + const err = {} + const next = {} + + errorPages.handler(err, req, res, next) + + expect(ldp.errorHandler).to.have.been.calledWith(err, req, res, next) + + expect(res.status).to.not.have.been.called() + expect(res.send).to.not.have.been.called() + }) + + it('defaults to status code 500 if none is specified in the error', () => { + const ldp = { noErrorPages: true } + const req = { app: { locals: { ldp } } } + const res = { status: sinon.stub(), send: sinon.stub(), header: sinon.stub() } + const err = { message: 'Unspecified error' } + const next = {} + + errorPages.handler(err, req, res, next) + + expect(res.status).to.have.been.calledWith(500) + expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') + expect(res.send).to.have.been.calledWith('Unspecified error\n') + }) + }) + + describe('sendErrorResponse()', () => { + it('should send http status code and error message', () => { + const statusCode = 404 + const error = { + message: 'Error description' + } + const res = { + status: sinon.stub(), + header: sinon.stub(), + send: sinon.stub() + } + + errorPages.sendErrorResponse(statusCode, res, error) + + expect(res.status).to.have.been.calledWith(404) + expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') + expect(res.send).to.have.been.calledWith('Error description\n') + }) + }) + + describe('setAuthenticateHeader()', () => { + it('should do nothing for a non-implemented auth method', () => { + const err = {} + const req = { + app: { locals: { authMethod: null } } + } + const res = { + set: sinon.stub() + } + + errorPages.setAuthenticateHeader(req, res, err) + + expect(res.set).to.not.have.been.called() + }) + }) + + describe('sendErrorPage()', () => { + it('falls back the default sendErrorResponse if no page is found', () => { + const statusCode = 400 + const res = { + status: sinon.stub(), + header: sinon.stub(), + send: sinon.stub() + } + const err = { message: 'Error description' } + const ldp = { errorPages: './' } + + return errorPages.sendErrorPage(statusCode, res, err, ldp) + .then(() => { + expect(res.status).to.have.been.calledWith(400) + expect(res.header).to.have.been.calledWith('Content-Type', 'text/plain;charset=utf-8') + expect(res.send).to.have.been.calledWith('Error description\n') + }) + }) + }) +}) diff --git a/test/unit/esm-imports.test.mjs b/test/unit/esm-imports.test.js similarity index 71% rename from test/unit/esm-imports.test.mjs rename to test/unit/esm-imports.test.js index e5b9da0c7..9972defbf 100644 --- a/test/unit/esm-imports.test.mjs +++ b/test/unit/esm-imports.test.js @@ -1,148 +1,148 @@ -import { describe, it } from 'mocha' -import { expect } from 'chai' -import { testESMImport, PerformanceTimer } from '../test-helpers.mjs' - -describe('ESM Module Import Tests', function () { - this.timeout(10000) - - describe('Core Utility Modules', () => { - it('should import debug.mjs with named exports', async () => { - const result = await testESMImport('../lib/debug.mjs') - - expect(result.success).to.be.true - expect(result.namedExports).to.include('handlers') - expect(result.namedExports).to.include('ACL') - expect(result.namedExports).to.include('fs') - expect(result.namedExports).to.include('metadata') - }) - - it('should import http-error.mjs with default export', async () => { - const result = await testESMImport('../lib/http-error.mjs') - - expect(result.success).to.be.true - expect(result.hasDefault).to.be.true - - const { default: HTTPError } = result.module - expect(typeof HTTPError).to.equal('function') - - const error = HTTPError(404, 'Not Found') - expect(error.status).to.equal(404) - expect(error.message).to.equal('Not Found') - }) - - it('should import utils.mjs with named exports', async () => { - const result = await testESMImport('../lib/utils.mjs') - - expect(result.success).to.be.true - expect(result.namedExports).to.include('getContentType') - expect(result.namedExports).to.include('pathBasename') - expect(result.namedExports).to.include('translate') - expect(result.namedExports).to.include('routeResolvedFile') - }) - }) - - describe('Handler Modules', () => { - it('should import all handler modules successfully', async () => { - const handlers = [ - '../lib/handlers/get.mjs', - '../lib/handlers/post.mjs', - '../lib/handlers/put.mjs', - '../lib/handlers/delete.mjs', - '../lib/handlers/copy.mjs', - '../lib/handlers/patch.mjs' - ] - - for (const handler of handlers) { - const result = await testESMImport(handler) - expect(result.success).to.be.true - expect(result.hasDefault).to.be.true - expect(typeof result.module.default).to.equal('function') - } - }) - - it('should import allow.mjs and validate permission function', async () => { - const result = await testESMImport('../lib/handlers/allow.mjs') - - expect(result.success).to.be.true - expect(result.hasDefault).to.be.true - - const { default: allow } = result.module - expect(typeof allow).to.equal('function') - - const readHandler = allow('Read') - expect(typeof readHandler).to.equal('function') - }) - }) - - describe('Infrastructure Modules', () => { - it('should import metadata.mjs with Metadata constructor', async () => { - const result = await testESMImport('../lib/metadata.mjs') - - expect(result.success).to.be.true - expect(result.namedExports).to.include('Metadata') - - const { Metadata } = result.module - const metadata = new Metadata() - expect(metadata.isResource).to.be.false - expect(metadata.isContainer).to.be.false - }) - - it('should import acl-checker.mjs with ACLChecker class', async () => { - const result = await testESMImport('../lib/acl-checker.mjs') - - expect(result.success).to.be.true - expect(result.hasDefault).to.be.true - expect(result.namedExports).to.include('DEFAULT_ACL_SUFFIX') - expect(result.namedExports).to.include('clearAclCache') - - const { default: ACLChecker, DEFAULT_ACL_SUFFIX } = result.module - expect(typeof ACLChecker).to.equal('function') - expect(DEFAULT_ACL_SUFFIX).to.equal('.acl') - }) - - it('should import lock.mjs with withLock function', async () => { - const result = await testESMImport('../lib/lock.mjs') - - expect(result.success).to.be.true - expect(result.hasDefault).to.be.true - - const { default: withLock } = result.module - expect(typeof withLock).to.equal('function') - }) - }) - - describe('Application Modules', () => { - it('should import ldp-middleware.mjs with router function', async () => { - const result = await testESMImport('../lib/ldp-middleware.mjs') - - expect(result.success).to.be.true - expect(result.hasDefault).to.be.true - - const { default: LdpMiddleware } = result.module - expect(typeof LdpMiddleware).to.equal('function') - }) - - it('should import main entry point index.mjs', async () => { - const result = await testESMImport('../index.mjs') - - expect(result.success).to.be.true - expect(result.hasDefault).to.be.true - expect(result.namedExports).to.include('createServer') - expect(result.namedExports).to.include('startCli') - }) - }) - - describe('Import Performance', () => { - it('should measure ESM import performance', async () => { - const timer = new PerformanceTimer() - - timer.start() - const result = await testESMImport('../index.mjs') - const duration = timer.end() - - expect(result.success).to.be.true - expect(duration).to.be.lessThan(1000) // Should import in less than 1 second - console.log(`ESM import took ${duration.toFixed(2)}ms`) - }) - }) -}) +import { describe, it } from 'mocha' +import { expect } from 'chai' +import { testESMImport, PerformanceTimer } from '../test-helpers.js' + +describe('ESM Module Import Tests', function () { + this.timeout(10000) + + describe('Core Utility Modules', () => { + it('should import debug.js with named exports', async () => { + const result = await testESMImport('../lib/debug.js') + + expect(result.success).to.be.true + expect(result.namedExports).to.include('handlers') + expect(result.namedExports).to.include('ACL') + expect(result.namedExports).to.include('fs') + expect(result.namedExports).to.include('metadata') + }) + + it('should import http-error.js with default export', async () => { + const result = await testESMImport('../lib/http-error.js') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + + const { default: HTTPError } = result.module + expect(typeof HTTPError).to.equal('function') + + const error = HTTPError(404, 'Not Found') + expect(error.status).to.equal(404) + expect(error.message).to.equal('Not Found') + }) + + it('should import utils.js with named exports', async () => { + const result = await testESMImport('../lib/utils.js') + + expect(result.success).to.be.true + expect(result.namedExports).to.include('getContentType') + expect(result.namedExports).to.include('pathBasename') + expect(result.namedExports).to.include('translate') + expect(result.namedExports).to.include('routeResolvedFile') + }) + }) + + describe('Handler Modules', () => { + it('should import all handler modules successfully', async () => { + const handlers = [ + '../lib/handlers/get.js', + '../lib/handlers/post.js', + '../lib/handlers/put.js', + '../lib/handlers/delete.js', + '../lib/handlers/copy.js', + '../lib/handlers/patch.js' + ] + + for (const handler of handlers) { + const result = await testESMImport(handler) + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + expect(typeof result.module.default).to.equal('function') + } + }) + + it('should import allow.js and validate permission function', async () => { + const result = await testESMImport('../lib/handlers/allow.js') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + + const { default: allow } = result.module + expect(typeof allow).to.equal('function') + + const readHandler = allow('Read') + expect(typeof readHandler).to.equal('function') + }) + }) + + describe('Infrastructure Modules', () => { + it('should import metadata.js with Metadata constructor', async () => { + const result = await testESMImport('../lib/metadata.js') + + expect(result.success).to.be.true + expect(result.namedExports).to.include('Metadata') + + const { Metadata } = result.module + const metadata = new Metadata() + expect(metadata.isResource).to.be.false + expect(metadata.isContainer).to.be.false + }) + + it('should import acl-checker.js with ACLChecker class', async () => { + const result = await testESMImport('../lib/acl-checker.js') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + expect(result.namedExports).to.include('DEFAULT_ACL_SUFFIX') + expect(result.namedExports).to.include('clearAclCache') + + const { default: ACLChecker, DEFAULT_ACL_SUFFIX } = result.module + expect(typeof ACLChecker).to.equal('function') + expect(DEFAULT_ACL_SUFFIX).to.equal('.acl') + }) + + it('should import lock.js with withLock function', async () => { + const result = await testESMImport('../lib/lock.js') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + + const { default: withLock } = result.module + expect(typeof withLock).to.equal('function') + }) + }) + + describe('Application Modules', () => { + it('should import ldp-middleware.js with router function', async () => { + const result = await testESMImport('../lib/ldp-middleware.js') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + + const { default: LdpMiddleware } = result.module + expect(typeof LdpMiddleware).to.equal('function') + }) + + it('should import main entry point index.js', async () => { + const result = await testESMImport('../index.js') + + expect(result.success).to.be.true + expect(result.hasDefault).to.be.true + expect(result.namedExports).to.include('createServer') + expect(result.namedExports).to.include('startCli') + }) + }) + + describe('Import Performance', () => { + it('should measure ESM import performance', async () => { + const timer = new PerformanceTimer() + + timer.start() + const result = await testESMImport('../index.js') + const duration = timer.end() + + expect(result.success).to.be.true + expect(duration).to.be.lessThan(1000) // Should import in less than 1 second + console.log(`ESM import took ${duration.toFixed(2)}ms`) + }) + }) +}) diff --git a/test/unit/force-user-test.mjs b/test/unit/force-user-test.js similarity index 93% rename from test/unit/force-user-test.mjs rename to test/unit/force-user-test.js index d707536c1..3d7f98e08 100644 --- a/test/unit/force-user-test.mjs +++ b/test/unit/force-user-test.js @@ -1,73 +1,73 @@ -import { describe, it, before } from 'mocha' -import chai from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' - -import forceUser from '../../lib/api/authn/force-user.mjs' - -const { expect } = chai -chai.use(sinonChai) - -const USER = 'https://ruben.verborgh.org/profile/#me' - -describe('Force User', () => { - describe('a forceUser handler', () => { - let app, handler - before(() => { - app = { use: sinon.stub() } - const argv = { forceUser: USER } - forceUser.initialize(app, argv) - handler = app.use.getCall(0).args[1] - }) - - it('adds a route on /', () => { - expect(app.use).to.have.callCount(1) - expect(app.use).to.have.been.calledWith('/') - }) - - describe('when called', () => { - let request, response - before(done => { - request = { session: {} } - response = { set: sinon.stub() } - handler(request, response, done) - }) - - it('sets session.userId to the user', () => { - expect(request.session).to.have.property('userId', USER) - }) - - it('does not set the User header', () => { - expect(response.set).to.have.callCount(0) - }) - }) - }) - - describe('a forceUser handler for TLS', () => { - let handler - before(() => { - const app = { use: sinon.stub() } - const argv = { forceUser: USER, auth: 'tls' } - forceUser.initialize(app, argv) - handler = app.use.getCall(0).args[1] - }) - - describe('when called', () => { - let request, response - before(done => { - request = { session: {} } - response = { set: sinon.stub() } - handler(request, response, done) - }) - - it('sets session.userId to the user', () => { - expect(request.session).to.have.property('userId', USER) - }) - - it('sets the User header', () => { - expect(response.set).to.have.callCount(1) - expect(response.set).to.have.been.calledWith('User', USER) - }) - }) - }) +import { describe, it, before } from 'mocha' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +import forceUser from '../../lib/api/authn/force-user.js' + +const { expect } = chai +chai.use(sinonChai) + +const USER = 'https://ruben.verborgh.org/profile/#me' + +describe('Force User', () => { + describe('a forceUser handler', () => { + let app, handler + before(() => { + app = { use: sinon.stub() } + const argv = { forceUser: USER } + forceUser.initialize(app, argv) + handler = app.use.getCall(0).args[1] + }) + + it('adds a route on /', () => { + expect(app.use).to.have.callCount(1) + expect(app.use).to.have.been.calledWith('/') + }) + + describe('when called', () => { + let request, response + before(done => { + request = { session: {} } + response = { set: sinon.stub() } + handler(request, response, done) + }) + + it('sets session.userId to the user', () => { + expect(request.session).to.have.property('userId', USER) + }) + + it('does not set the User header', () => { + expect(response.set).to.have.callCount(0) + }) + }) + }) + + describe('a forceUser handler for TLS', () => { + let handler + before(() => { + const app = { use: sinon.stub() } + const argv = { forceUser: USER, auth: 'tls' } + forceUser.initialize(app, argv) + handler = app.use.getCall(0).args[1] + }) + + describe('when called', () => { + let request, response + before(done => { + request = { session: {} } + response = { set: sinon.stub() } + handler(request, response, done) + }) + + it('sets session.userId to the user', () => { + expect(request.session).to.have.property('userId', USER) + }) + + it('sets the User header', () => { + expect(response.set).to.have.callCount(1) + expect(response.set).to.have.been.calledWith('User', USER) + }) + }) + }) }) diff --git a/test/unit/getAvailableUrl-test.mjs b/test/unit/getAvailableUrl-test.js similarity index 94% rename from test/unit/getAvailableUrl-test.mjs rename to test/unit/getAvailableUrl-test.js index 4b47ac886..9afc1b81d 100644 --- a/test/unit/getAvailableUrl-test.mjs +++ b/test/unit/getAvailableUrl-test.js @@ -1,30 +1,30 @@ -import { strict as assert } from 'assert' -import LDP from '../../lib/ldp.mjs' - -export async function testNoExistingResource () { - const rm = { - resolveUrl: (hostname, containerURI) => `https://${hostname}/root${containerURI}/`, - mapUrlToFile: async () => { throw new Error('Not found') } - } - const ldp = new LDP({ resourceMapper: rm }) - const url = await ldp.getAvailableUrl('host.test', '/container', { slug: 'name.txt', extension: '', container: false }) - assert.equal(url, 'https://host.test/root/container/name.txt') -} - -export async function testExistingResourcePrefixes () { - let called = 0 - const rm = { - resolveUrl: (hostname, containerURI) => `https://${hostname}/root${containerURI}/`, - mapUrlToFile: async () => { - called += 1 - // First call indicates file exists (resolve), so return some object - if (called === 1) return { path: '/some/path' } - // Subsequent calls simulate not found - throw new Error('Not found') - } - } - const ldp = new LDP({ resourceMapper: rm }) - const url = await ldp.getAvailableUrl('host.test', '/container', { slug: 'name.txt', extension: '', container: false }) - // Should contain a uuid-prefix before name.txt, i.e. -name.txt - assert.ok(url.endsWith('-name.txt') || url.includes('-name.txt')) -} +import { strict as assert } from 'assert' +import LDP from '../../lib/ldp.js' + +export async function testNoExistingResource () { + const rm = { + resolveUrl: (hostname, containerURI) => `https://${hostname}/root${containerURI}/`, + mapUrlToFile: async () => { throw new Error('Not found') } + } + const ldp = new LDP({ resourceMapper: rm }) + const url = await ldp.getAvailableUrl('host.test', '/container', { slug: 'name.txt', extension: '', container: false }) + assert.equal(url, 'https://host.test/root/container/name.txt') +} + +export async function testExistingResourcePrefixes () { + let called = 0 + const rm = { + resolveUrl: (hostname, containerURI) => `https://${hostname}/root${containerURI}/`, + mapUrlToFile: async () => { + called += 1 + // First call indicates file exists (resolve), so return some object + if (called === 1) return { path: '/some/path' } + // Subsequent calls simulate not found + throw new Error('Not found') + } + } + const ldp = new LDP({ resourceMapper: rm }) + const url = await ldp.getAvailableUrl('host.test', '/container', { slug: 'name.txt', extension: '', container: false }) + // Should contain a uuid-prefix before name.txt, i.e. -name.txt + assert.ok(url.endsWith('-name.txt') || url.includes('-name.txt')) +} diff --git a/test/unit/getTrustedOrigins-test.mjs b/test/unit/getTrustedOrigins-test.js similarity index 95% rename from test/unit/getTrustedOrigins-test.mjs rename to test/unit/getTrustedOrigins-test.js index cb7877bb6..5c016535f 100644 --- a/test/unit/getTrustedOrigins-test.mjs +++ b/test/unit/getTrustedOrigins-test.js @@ -1,20 +1,20 @@ -import { describe, it } from 'mocha' -import { assert } from 'chai' -import LDP from '../../lib/ldp.mjs' - -describe('LDP.getTrustedOrigins', () => { - it('includes resourceMapper.resolveUrl(hostname), trustedOrigins and serverUri when multiuser', () => { - const rm = { resolveUrl: (hostname) => `https://${hostname}/` } - const ldp = new LDP({ resourceMapper: rm, trustedOrigins: ['https://trusted.example/'], multiuser: true, serverUri: 'https://server.example/' }) - const res = ldp.getTrustedOrigins({ hostname: 'host.test' }) - assert.includeMembers(res, ['https://host.test/', 'https://trusted.example/', 'https://server.example/']) - }) - - it('omits serverUri when not multiuser', () => { - const rm = { resolveUrl: (hostname) => `https://${hostname}/` } - const ldp = new LDP({ resourceMapper: rm, trustedOrigins: ['https://trusted.example/'], multiuser: false, serverUri: 'https://server.example/' }) - const res = ldp.getTrustedOrigins({ hostname: 'host.test' }) - assert.includeMembers(res, ['https://host.test/', 'https://trusted.example/']) - assert.notInclude(res, 'https://server.example/') - }) -}) +import { describe, it } from 'mocha' +import { assert } from 'chai' +import LDP from '../../lib/ldp.js' + +describe('LDP.getTrustedOrigins', () => { + it('includes resourceMapper.resolveUrl(hostname), trustedOrigins and serverUri when multiuser', () => { + const rm = { resolveUrl: (hostname) => `https://${hostname}/` } + const ldp = new LDP({ resourceMapper: rm, trustedOrigins: ['https://trusted.example/'], multiuser: true, serverUri: 'https://server.example/' }) + const res = ldp.getTrustedOrigins({ hostname: 'host.test' }) + assert.includeMembers(res, ['https://host.test/', 'https://trusted.example/', 'https://server.example/']) + }) + + it('omits serverUri when not multiuser', () => { + const rm = { resolveUrl: (hostname) => `https://${hostname}/` } + const ldp = new LDP({ resourceMapper: rm, trustedOrigins: ['https://trusted.example/'], multiuser: false, serverUri: 'https://server.example/' }) + const res = ldp.getTrustedOrigins({ hostname: 'host.test' }) + assert.includeMembers(res, ['https://host.test/', 'https://trusted.example/']) + assert.notInclude(res, 'https://server.example/') + }) +}) diff --git a/test/unit/login-request-test.mjs b/test/unit/login-request-test.js similarity index 93% rename from test/unit/login-request-test.mjs rename to test/unit/login-request-test.js index 306f7bcf3..610def0cd 100644 --- a/test/unit/login-request-test.mjs +++ b/test/unit/login-request-test.js @@ -1,246 +1,246 @@ -import { describe, it, beforeEach } from 'mocha' -import chai from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' -import dirtyChai from 'dirty-chai' - -import HttpMocks from 'node-mocks-http' -import AuthRequest from '../../lib/requests/auth-request.mjs' -import { LoginRequest } from '../../lib/requests/login-request.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.use(dirtyChai) -chai.should() - -const mockUserStore = { - findUser: () => { return Promise.resolve(true) }, - matchPassword: (user, password) => { return Promise.resolve(user) } -} - -const authMethod = 'oidc' -const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) -const accountManager = AccountManager.from({ host, authMethod }) -const localAuth = { password: true, tls: true } - -describe('LoginRequest', () => { - describe('loginPassword()', () => { - let res, req - - beforeEach(() => { - req = { - app: { locals: { oidc: { users: mockUserStore }, localAuth, accountManager } }, - body: { username: 'alice', password: '12345' } - } - res = HttpMocks.createResponse() - }) - - it('should create a LoginRequest instance', () => { - const fromParams = sinon.spy(LoginRequest, 'fromParams') - const loginStub = sinon.stub(LoginRequest, 'login') - .returns(Promise.resolve()) - - return LoginRequest.loginPassword(req, res) - .then(() => { - expect(fromParams).to.have.been.calledWith(req, res) - fromParams.restore() - loginStub.restore() - }) - }) - - it('should invoke login()', () => { - const login = sinon.spy(LoginRequest, 'login') - - return LoginRequest.loginPassword(req, res) - .then(() => { - expect(login).to.have.been.called() - login.restore() - }) - }) - }) - - describe('loginTls()', () => { - let res, req - - beforeEach(() => { - req = { - connection: {}, - app: { locals: { localAuth, accountManager } } - } - res = HttpMocks.createResponse() - }) - - it('should create a LoginRequest instance', () => { - const fromParams = sinon.spy(LoginRequest, 'fromParams') - const loginStub = sinon.stub(LoginRequest, 'login') - .returns(Promise.resolve()) - - return LoginRequest.loginTls(req, res) - .then(() => { - expect(fromParams).to.have.been.calledWith(req, res) - fromParams.restore() - loginStub.restore() - }) - }) - - it('should invoke login()', () => { - const login = sinon.spy(LoginRequest, 'login') - - return LoginRequest.loginTls(req, res) - .then(() => { - expect(login).to.have.been.called() - login.restore() - }) - }) - }) - - describe('fromParams()', () => { - const session = {} - const req = { - session, - app: { locals: { accountManager } }, - body: { username: 'alice', password: '12345' } - } - const res = HttpMocks.createResponse() - - it('should return a LoginRequest instance', () => { - const request = LoginRequest.fromParams(req, res) - - expect(request.response).to.equal(res) - expect(request.session).to.equal(session) - expect(request.accountManager).to.equal(accountManager) - }) - - it('should initialize the query params', () => { - const requestOptions = sinon.spy(AuthRequest, 'requestOptions') - LoginRequest.fromParams(req, res) - - expect(requestOptions).to.have.been.calledWith(req) - requestOptions.restore() - }) - }) - - describe('login()', () => { - const userStore = mockUserStore - let response - - const options = { - userStore, - accountManager, - localAuth: {} - } - - beforeEach(() => { - response = HttpMocks.createResponse() - }) - - it('should call initUserSession() for a valid user', () => { - const validUser = {} - options.response = response - options.authenticator = { - findValidUser: sinon.stub().resolves(validUser) - } - - const request = new LoginRequest(options) - - const initUserSession = sinon.spy(request, 'initUserSession') - - return LoginRequest.login(request) - .then(() => { - expect(initUserSession).to.have.been.calledWith(validUser) - }) - }) - - it('should call redirectPostLogin()', () => { - const validUser = {} - options.response = response - options.authenticator = { - findValidUser: sinon.stub().resolves(validUser) - } - - const request = new LoginRequest(options) - - const redirectPostLogin = sinon.spy(request, 'redirectPostLogin') - - return LoginRequest.login(request) - .then(() => { - expect(redirectPostLogin).to.have.been.calledWith(validUser) - }) - }) - }) - - describe('postLoginUrl()', () => { - it('should return the user account uri if no redirect_uri param', () => { - const request = new LoginRequest({ authQueryParams: {} }) - - const aliceAccount = 'https://alice.example.com' - const user = { accountUri: aliceAccount } - - expect(request.postLoginUrl(user)).to.equal(aliceAccount) - }) - }) - - describe('redirectPostLogin()', () => { - it('should redirect to the /sharing url if response_type includes token', () => { - const res = HttpMocks.createResponse() - const authUrl = 'https://localhost:8443/sharing?response_type=token' - const validUser = accountManager.userAccountFrom({ username: 'alice' }) - - const authQueryParams = { - response_type: 'token' - } - - const options = { accountManager, authQueryParams, response: res } - const request = new LoginRequest(options) - - request.authorizeUrl = sinon.stub().returns(authUrl) - - request.redirectPostLogin(validUser) - - expect(res.statusCode).to.equal(302) - expect(res._getRedirectUrl()).to.equal(authUrl) - }) - - it('should redirect to account uri if no client_id present', () => { - const res = HttpMocks.createResponse() - const authUrl = 'https://localhost/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback' - const validUser = accountManager.userAccountFrom({ username: 'alice' }) - - const authQueryParams = {} - - const options = { accountManager, authQueryParams, response: res } - const request = new LoginRequest(options) - - request.authorizeUrl = sinon.stub().returns(authUrl) - - request.redirectPostLogin(validUser) - - const expectedUri = accountManager.accountUriFor('alice') - expect(res.statusCode).to.equal(302) - expect(res._getRedirectUrl()).to.equal(expectedUri) - }) - - it('should redirect to account uri if redirect_uri is string "undefined"', () => { - const res = HttpMocks.createResponse() - const authUrl = 'https://localhost/authorize?client_id=123' - const validUser = accountManager.userAccountFrom({ username: 'alice' }) - - const body = { redirect_uri: 'undefined' } - - const options = { accountManager, response: res } - const request = new LoginRequest(options) - request.authQueryParams = AuthRequest.extractAuthParams({ body }) - - request.authorizeUrl = sinon.stub().returns(authUrl) - - request.redirectPostLogin(validUser) - - const expectedUri = accountManager.accountUriFor('alice') - - expect(res.statusCode).to.equal(302) - expect(res._getRedirectUrl()).to.equal(expectedUri) - }) - }) +import { describe, it, beforeEach } from 'mocha' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' + +import HttpMocks from 'node-mocks-http' +import AuthRequest from '../../lib/requests/auth-request.js' +import { LoginRequest } from '../../lib/requests/login-request.js' +import SolidHost from '../../lib/models/solid-host.js' +import AccountManager from '../../lib/models/account-manager.js' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() + +const mockUserStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: (user, password) => { return Promise.resolve(user) } +} + +const authMethod = 'oidc' +const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) +const accountManager = AccountManager.from({ host, authMethod }) +const localAuth = { password: true, tls: true } + +describe('LoginRequest', () => { + describe('loginPassword()', () => { + let res, req + + beforeEach(() => { + req = { + app: { locals: { oidc: { users: mockUserStore }, localAuth, accountManager } }, + body: { username: 'alice', password: '12345' } + } + res = HttpMocks.createResponse() + }) + + it('should create a LoginRequest instance', () => { + const fromParams = sinon.spy(LoginRequest, 'fromParams') + const loginStub = sinon.stub(LoginRequest, 'login') + .returns(Promise.resolve()) + + return LoginRequest.loginPassword(req, res) + .then(() => { + expect(fromParams).to.have.been.calledWith(req, res) + fromParams.restore() + loginStub.restore() + }) + }) + + it('should invoke login()', () => { + const login = sinon.spy(LoginRequest, 'login') + + return LoginRequest.loginPassword(req, res) + .then(() => { + expect(login).to.have.been.called() + login.restore() + }) + }) + }) + + describe('loginTls()', () => { + let res, req + + beforeEach(() => { + req = { + connection: {}, + app: { locals: { localAuth, accountManager } } + } + res = HttpMocks.createResponse() + }) + + it('should create a LoginRequest instance', () => { + const fromParams = sinon.spy(LoginRequest, 'fromParams') + const loginStub = sinon.stub(LoginRequest, 'login') + .returns(Promise.resolve()) + + return LoginRequest.loginTls(req, res) + .then(() => { + expect(fromParams).to.have.been.calledWith(req, res) + fromParams.restore() + loginStub.restore() + }) + }) + + it('should invoke login()', () => { + const login = sinon.spy(LoginRequest, 'login') + + return LoginRequest.loginTls(req, res) + .then(() => { + expect(login).to.have.been.called() + login.restore() + }) + }) + }) + + describe('fromParams()', () => { + const session = {} + const req = { + session, + app: { locals: { accountManager } }, + body: { username: 'alice', password: '12345' } + } + const res = HttpMocks.createResponse() + + it('should return a LoginRequest instance', () => { + const request = LoginRequest.fromParams(req, res) + + expect(request.response).to.equal(res) + expect(request.session).to.equal(session) + expect(request.accountManager).to.equal(accountManager) + }) + + it('should initialize the query params', () => { + const requestOptions = sinon.spy(AuthRequest, 'requestOptions') + LoginRequest.fromParams(req, res) + + expect(requestOptions).to.have.been.calledWith(req) + requestOptions.restore() + }) + }) + + describe('login()', () => { + const userStore = mockUserStore + let response + + const options = { + userStore, + accountManager, + localAuth: {} + } + + beforeEach(() => { + response = HttpMocks.createResponse() + }) + + it('should call initUserSession() for a valid user', () => { + const validUser = {} + options.response = response + options.authenticator = { + findValidUser: sinon.stub().resolves(validUser) + } + + const request = new LoginRequest(options) + + const initUserSession = sinon.spy(request, 'initUserSession') + + return LoginRequest.login(request) + .then(() => { + expect(initUserSession).to.have.been.calledWith(validUser) + }) + }) + + it('should call redirectPostLogin()', () => { + const validUser = {} + options.response = response + options.authenticator = { + findValidUser: sinon.stub().resolves(validUser) + } + + const request = new LoginRequest(options) + + const redirectPostLogin = sinon.spy(request, 'redirectPostLogin') + + return LoginRequest.login(request) + .then(() => { + expect(redirectPostLogin).to.have.been.calledWith(validUser) + }) + }) + }) + + describe('postLoginUrl()', () => { + it('should return the user account uri if no redirect_uri param', () => { + const request = new LoginRequest({ authQueryParams: {} }) + + const aliceAccount = 'https://alice.example.com' + const user = { accountUri: aliceAccount } + + expect(request.postLoginUrl(user)).to.equal(aliceAccount) + }) + }) + + describe('redirectPostLogin()', () => { + it('should redirect to the /sharing url if response_type includes token', () => { + const res = HttpMocks.createResponse() + const authUrl = 'https://localhost:8443/sharing?response_type=token' + const validUser = accountManager.userAccountFrom({ username: 'alice' }) + + const authQueryParams = { + response_type: 'token' + } + + const options = { accountManager, authQueryParams, response: res } + const request = new LoginRequest(options) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(authUrl) + }) + + it('should redirect to account uri if no client_id present', () => { + const res = HttpMocks.createResponse() + const authUrl = 'https://localhost/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback' + const validUser = accountManager.userAccountFrom({ username: 'alice' }) + + const authQueryParams = {} + + const options = { accountManager, authQueryParams, response: res } + const request = new LoginRequest(options) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + const expectedUri = accountManager.accountUriFor('alice') + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(expectedUri) + }) + + it('should redirect to account uri if redirect_uri is string "undefined"', () => { + const res = HttpMocks.createResponse() + const authUrl = 'https://localhost/authorize?client_id=123' + const validUser = accountManager.userAccountFrom({ username: 'alice' }) + + const body = { redirect_uri: 'undefined' } + + const options = { accountManager, response: res } + const request = new LoginRequest(options) + request.authQueryParams = AuthRequest.extractAuthParams({ body }) + + request.authorizeUrl = sinon.stub().returns(authUrl) + + request.redirectPostLogin(validUser) + + const expectedUri = accountManager.accountUriFor('alice') + + expect(res.statusCode).to.equal(302) + expect(res._getRedirectUrl()).to.equal(expectedUri) + }) + }) }) diff --git a/test/unit/oidc-manager-test.mjs b/test/unit/oidc-manager-test.js similarity index 90% rename from test/unit/oidc-manager-test.mjs rename to test/unit/oidc-manager-test.js index ec40ff00a..3d196f95f 100644 --- a/test/unit/oidc-manager-test.mjs +++ b/test/unit/oidc-manager-test.js @@ -1,49 +1,49 @@ -// import { createRequire } from 'module' -import { fileURLToPath } from 'url' -import path from 'path' -import chai from 'chai' - -// const require = createRequire(import.meta.url) -// const OidcManager = require('../../lib/models/oidc-manager') -// const SolidHost = require('../../lib/models/solid-host') -import * as OidcManager from '../../lib/models/oidc-manager.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' - -const { expect } = chai - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -describe('OidcManager', () => { - describe('fromServerConfig()', () => { - it('should error if no serverUri is provided in argv', () => { - - }) - - it('should result in an initialized oidc object', () => { - const serverUri = 'https://localhost:8443' - const host = SolidHost.from({ serverUri }) - - const dbPath = path.join(__dirname, '../resources/db') - const saltRounds = 5 - const argv = { - host, - dbPath, - saltRounds - } - - const oidc = OidcManager.fromServerConfig(argv) - - expect(oidc.rs.defaults.query).to.be.true - const clientsPath = oidc.clients.store.backend.path - const usersPath = oidc.users.backend.path - // Check that the clients path contains an 'rp' segment (or 'clients') to handle layout differences - const clientsSegments = clientsPath.split(path.sep) - expect(clientsSegments.includes('rp') || clientsSegments.includes('clients')).to.be.true - expect(oidc.provider.issuer).to.equal(serverUri) - const usersSegments = usersPath.split(path.sep) - expect(usersSegments.includes('users')).to.be.true - expect(oidc.users.saltRounds).to.equal(saltRounds) - }) - }) -}) +// import { createRequire } from 'module' +import { fileURLToPath } from 'url' +import path from 'path' +import chai from 'chai' + +// const require = createRequire(import.meta.url) +// const OidcManager = require('../../lib/models/oidc-manager') +// const SolidHost = require('../../lib/models/solid-host') +import * as OidcManager from '../../lib/models/oidc-manager.js' +import SolidHost from '../../lib/models/solid-host.js' + +const { expect } = chai + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +describe('OidcManager', () => { + describe('fromServerConfig()', () => { + it('should error if no serverUri is provided in argv', () => { + + }) + + it('should result in an initialized oidc object', () => { + const serverUri = 'https://localhost:8443' + const host = SolidHost.from({ serverUri }) + + const dbPath = path.join(__dirname, '../resources/db') + const saltRounds = 5 + const argv = { + host, + dbPath, + saltRounds + } + + const oidc = OidcManager.fromServerConfig(argv) + + expect(oidc.rs.defaults.query).to.be.true + const clientsPath = oidc.clients.store.backend.path + const usersPath = oidc.users.backend.path + // Check that the clients path contains an 'rp' segment (or 'clients') to handle layout differences + const clientsSegments = clientsPath.split(path.sep) + expect(clientsSegments.includes('rp') || clientsSegments.includes('clients')).to.be.true + expect(oidc.provider.issuer).to.equal(serverUri) + const usersSegments = usersPath.split(path.sep) + expect(usersSegments.includes('users')).to.be.true + expect(oidc.users.saltRounds).to.equal(saltRounds) + }) + }) +}) diff --git a/test/unit/password-authenticator-test.mjs b/test/unit/password-authenticator-test.js similarity index 93% rename from test/unit/password-authenticator-test.mjs rename to test/unit/password-authenticator-test.js index 9540d71d9..32d3456f5 100644 --- a/test/unit/password-authenticator-test.mjs +++ b/test/unit/password-authenticator-test.js @@ -1,125 +1,125 @@ -import { describe, it, beforeEach, afterEach } from 'mocha' -import chai from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' -import dirtyChai from 'dirty-chai' - -import { PasswordAuthenticator } from '../../lib/models/authenticator.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.use(dirtyChai) -chai.should() - -const mockUserStore = { - findUser: () => { return Promise.resolve(true) }, - matchPassword: (user, password) => { return Promise.resolve(user) } -} - -const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) -const accountManager = AccountManager.from({ host }) - -describe('PasswordAuthenticator', () => { - describe('fromParams()', () => { - const req = { - body: { username: 'alice', password: '12345' } - } - const options = { userStore: mockUserStore, accountManager } - - it('should return a PasswordAuthenticator instance', () => { - const pwAuth = PasswordAuthenticator.fromParams(req, options) - - expect(pwAuth.userStore).to.equal(mockUserStore) - expect(pwAuth.accountManager).to.equal(accountManager) - expect(pwAuth.username).to.equal('alice') - expect(pwAuth.password).to.equal('12345') - }) - - it('should init with undefined username and password if no body is provided', () => { - const req = {} - const pwAuth = PasswordAuthenticator.fromParams(req, options) - - expect(pwAuth.username).to.be.undefined() - expect(pwAuth.password).to.be.undefined() - }) - }) - - describe('findValidUser()', () => { - let pwAuth, sandbox - - beforeEach(() => { - sandbox = sinon.createSandbox() - const req = { - body: { username: 'alice', password: '12345' } - } - const options = { userStore: mockUserStore, accountManager } - pwAuth = PasswordAuthenticator.fromParams(req, options) - }) - - afterEach(() => { - sandbox.restore() - }) - - it('should resolve with user if credentials are valid', () => { - sandbox.stub(mockUserStore, 'findUser') - .resolves({ username: 'alice' }) - sandbox.stub(mockUserStore, 'matchPassword') - .resolves({ username: 'alice' }) - - return pwAuth.findValidUser() - .then(user => { - expect(user.username).to.equal('alice') - }) - }) - - it('should reject if user is not found', () => { - sandbox.stub(mockUserStore, 'findUser') - .resolves(null) - - return pwAuth.findValidUser() - .catch(error => { - expect(error.message).to.include('Invalid username/password combination.') - }) - }) - - it('should reject if password does not match', () => { - sandbox.stub(mockUserStore, 'findUser') - .resolves({ username: 'alice' }) - sandbox.stub(mockUserStore, 'matchPassword') - .resolves(null) - - return pwAuth.findValidUser() - .catch(error => { - expect(error.message).to.include('Invalid username/password combination.') - }) - }) - - it('should reject with error if userStore throws', () => { - sandbox.stub(mockUserStore, 'findUser') - .rejects(new Error('Database error')) - - return pwAuth.findValidUser() - .catch(error => { - expect(error.message).to.equal('Database error') - }) - }) - }) - - describe('validate()', () => { - it('should throw a 400 error if no username was provided', () => { - const options = { username: null, password: '12345' } - const pwAuth = new PasswordAuthenticator(options) - - expect(() => pwAuth.validate()).to.throw('Username required') - }) - - it('should throw a 400 error if no password was provided', () => { - const options = { username: 'alice', password: null } - const pwAuth = new PasswordAuthenticator(options) - - expect(() => pwAuth.validate()).to.throw('Password required') - }) - }) -}) +import { describe, it, beforeEach, afterEach } from 'mocha' +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' + +import { PasswordAuthenticator } from '../../lib/models/authenticator.js' +import SolidHost from '../../lib/models/solid-host.js' +import AccountManager from '../../lib/models/account-manager.js' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.should() + +const mockUserStore = { + findUser: () => { return Promise.resolve(true) }, + matchPassword: (user, password) => { return Promise.resolve(user) } +} + +const host = SolidHost.from({ serverUri: 'https://localhost:8443' }) +const accountManager = AccountManager.from({ host }) + +describe('PasswordAuthenticator', () => { + describe('fromParams()', () => { + const req = { + body: { username: 'alice', password: '12345' } + } + const options = { userStore: mockUserStore, accountManager } + + it('should return a PasswordAuthenticator instance', () => { + const pwAuth = PasswordAuthenticator.fromParams(req, options) + + expect(pwAuth.userStore).to.equal(mockUserStore) + expect(pwAuth.accountManager).to.equal(accountManager) + expect(pwAuth.username).to.equal('alice') + expect(pwAuth.password).to.equal('12345') + }) + + it('should init with undefined username and password if no body is provided', () => { + const req = {} + const pwAuth = PasswordAuthenticator.fromParams(req, options) + + expect(pwAuth.username).to.be.undefined() + expect(pwAuth.password).to.be.undefined() + }) + }) + + describe('findValidUser()', () => { + let pwAuth, sandbox + + beforeEach(() => { + sandbox = sinon.createSandbox() + const req = { + body: { username: 'alice', password: '12345' } + } + const options = { userStore: mockUserStore, accountManager } + pwAuth = PasswordAuthenticator.fromParams(req, options) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('should resolve with user if credentials are valid', () => { + sandbox.stub(mockUserStore, 'findUser') + .resolves({ username: 'alice' }) + sandbox.stub(mockUserStore, 'matchPassword') + .resolves({ username: 'alice' }) + + return pwAuth.findValidUser() + .then(user => { + expect(user.username).to.equal('alice') + }) + }) + + it('should reject if user is not found', () => { + sandbox.stub(mockUserStore, 'findUser') + .resolves(null) + + return pwAuth.findValidUser() + .catch(error => { + expect(error.message).to.include('Invalid username/password combination.') + }) + }) + + it('should reject if password does not match', () => { + sandbox.stub(mockUserStore, 'findUser') + .resolves({ username: 'alice' }) + sandbox.stub(mockUserStore, 'matchPassword') + .resolves(null) + + return pwAuth.findValidUser() + .catch(error => { + expect(error.message).to.include('Invalid username/password combination.') + }) + }) + + it('should reject with error if userStore throws', () => { + sandbox.stub(mockUserStore, 'findUser') + .rejects(new Error('Database error')) + + return pwAuth.findValidUser() + .catch(error => { + expect(error.message).to.equal('Database error') + }) + }) + }) + + describe('validate()', () => { + it('should throw a 400 error if no username was provided', () => { + const options = { username: null, password: '12345' } + const pwAuth = new PasswordAuthenticator(options) + + expect(() => pwAuth.validate()).to.throw('Username required') + }) + + it('should throw a 400 error if no password was provided', () => { + const options = { username: 'alice', password: null } + const pwAuth = new PasswordAuthenticator(options) + + expect(() => pwAuth.validate()).to.throw('Password required') + }) + }) +}) diff --git a/test/unit/password-change-request-test.mjs b/test/unit/password-change-request-test.js similarity index 96% rename from test/unit/password-change-request-test.mjs rename to test/unit/password-change-request-test.js index 3a8529002..c50813ad6 100644 --- a/test/unit/password-change-request-test.mjs +++ b/test/unit/password-change-request-test.js @@ -1,259 +1,259 @@ -import chai from 'chai' -import sinon from 'sinon' -import dirtyChai from 'dirty-chai' -import sinonChai from 'sinon-chai' -import HttpMocks from 'node-mocks-http' -// const PasswordChangeRequest = require('../../lib/requests/password-change-request') -// const SolidHost = require('../../lib/models/solid-host') -import PasswordChangeRequest from '../../lib/requests/password-change-request.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' - -const { expect } = chai -chai.use(dirtyChai) -chai.use(sinonChai) -chai.should() - -describe('PasswordChangeRequest', () => { - sinon.spy(PasswordChangeRequest.prototype, 'error') - - describe('constructor()', () => { - it('should initialize a request instance from options', () => { - const res = HttpMocks.createResponse() - - const accountManager = {} - const userStore = {} - - const options = { - accountManager, - userStore, - returnToUrl: 'https://example.com/resource', - response: res, - token: '12345', - newPassword: 'swordfish' - } - - const request = new PasswordChangeRequest(options) - - expect(request.returnToUrl).to.equal(options.returnToUrl) - expect(request.response).to.equal(res) - expect(request.token).to.equal(options.token) - expect(request.newPassword).to.equal(options.newPassword) - expect(request.accountManager).to.equal(accountManager) - expect(request.userStore).to.equal(userStore) - }) - }) - - describe('fromParams()', () => { - it('should return a request instance from options', () => { - const returnToUrl = 'https://example.com/resource' - const token = '12345' - const newPassword = 'swordfish' - const accountManager = {} - const userStore = {} - - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { returnToUrl, token }, - body: { newPassword } - } - const res = HttpMocks.createResponse() - - const request = PasswordChangeRequest.fromParams(req, res) - - expect(request.returnToUrl).to.equal(returnToUrl) - expect(request.response).to.equal(res) - expect(request.token).to.equal(token) - expect(request.newPassword).to.equal(newPassword) - expect(request.accountManager).to.equal(accountManager) - expect(request.userStore).to.equal(userStore) - }) - }) - - describe('get()', () => { - const returnToUrl = 'https://example.com/resource' - const token = '12345' - const userStore = {} - const res = HttpMocks.createResponse() - sinon.spy(res, 'render') - - it('should create an instance and render a change password form', () => { - const accountManager = { - validateResetToken: sinon.stub().resolves(true) - } - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { returnToUrl, token } - } - - return PasswordChangeRequest.get(req, res) - .then(() => { - expect(accountManager.validateResetToken) - .to.have.been.called() - expect(res.render).to.have.been.calledWith('auth/change-password', - { returnToUrl, token, validToken: true }) - }) - }) - - it('should display an error message on an invalid token', () => { - const accountManager = { - validateResetToken: sinon.stub().throws() - } - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { returnToUrl, token } - } - - return PasswordChangeRequest.get(req, res) - .then(() => { - expect(PasswordChangeRequest.prototype.error) - .to.have.been.called() - }) - }) - }) - - describe('post()', () => { - it('creates a request instance and invokes handlePost()', () => { - sinon.spy(PasswordChangeRequest, 'handlePost') - - const returnToUrl = 'https://example.com/resource' - const token = '12345' - const newPassword = 'swordfish' - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const alice = { - webId: 'https://alice.example.com/#me' - } - const storedToken = { webId: alice.webId } - const store = { - findUser: sinon.stub().resolves(alice), - updatePassword: sinon.stub() - } - const accountManager = { - host, - store, - userAccountFrom: sinon.stub().resolves(alice), - validateResetToken: sinon.stub().resolves(storedToken) - } - - accountManager.accountExists = sinon.stub().resolves(true) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendPasswordResetEmail = sinon.stub().resolves() - - const req = { - app: { locals: { accountManager, oidc: { users: store } } }, - query: { returnToUrl }, - body: { token, newPassword } - } - const res = HttpMocks.createResponse() - - return PasswordChangeRequest.post(req, res) - .then(() => { - expect(PasswordChangeRequest.handlePost).to.have.been.called() - }) - }) - }) - - describe('handlePost()', () => { - it('should display error message if validation error encountered', () => { - const returnToUrl = 'https://example.com/resource' - const token = '12345' - const userStore = {} - const res = HttpMocks.createResponse() - const accountManager = { - validateResetToken: sinon.stub().throws() - } - const req = { - app: { locals: { accountManager, oidc: { users: userStore } } }, - query: { returnToUrl, token } - } - - const request = PasswordChangeRequest.fromParams(req, res) - - return PasswordChangeRequest.handlePost(request) - .then(() => { - expect(PasswordChangeRequest.prototype.error) - .to.have.been.called() - }) - }) - }) - - describe('validateToken()', () => { - it('should return false if no token is present', () => { - const accountManager = { - validateResetToken: sinon.stub() - } - const request = new PasswordChangeRequest({ accountManager, token: null }) - - return request.validateToken() - .then(result => { - expect(result).to.be.false() - expect(accountManager.validateResetToken).to.not.have.been.called() - }) - }) - }) - - describe('validatePost()', () => { - it('should throw an error if no new password was entered', () => { - const request = new PasswordChangeRequest({ newPassword: null }) - - expect(() => request.validatePost()).to.throw('Please enter a new password') - }) - }) - - describe('error()', () => { - it('should invoke renderForm() with the error', () => { - const request = new PasswordChangeRequest({}) - request.renderForm = sinon.stub() - const error = new Error('error message') - - request.error(error) - - expect(request.renderForm).to.have.been.calledWith(error) - }) - }) - - describe('changePassword()', () => { - it('should create a new user store entry if none exists', () => { - // this would be the case for legacy pre-user-store accounts - const webId = 'https://alice.example.com/#me' - const user = { webId, id: webId } - const accountManager = { - userAccountFrom: sinon.stub().returns(user) - } - const userStore = { - findUser: sinon.stub().resolves(null), // no user found - createUser: sinon.stub().resolves(), - updatePassword: sinon.stub().resolves() - } - - const options = { - accountManager, userStore, newPassword: 'swordfish' - } - const request = new PasswordChangeRequest(options) - - return request.changePassword(user) - .then(() => { - expect(userStore.createUser).to.have.been.calledWith(user, options.newPassword) - }) - }) - }) - - describe('renderForm()', () => { - it('should set response status to error status, if error exists', () => { - const returnToUrl = 'https://example.com/resource' - const token = '12345' - const response = HttpMocks.createResponse() - sinon.spy(response, 'render') - - const options = { returnToUrl, token, response } - - const request = new PasswordChangeRequest(options) - - const error = new Error('error message') - - request.renderForm(error) - - expect(response.render).to.have.been.calledWith('auth/change-password', - { validToken: false, token, returnToUrl, error: 'error message' }) - }) - }) -}) +import chai from 'chai' +import sinon from 'sinon' +import dirtyChai from 'dirty-chai' +import sinonChai from 'sinon-chai' +import HttpMocks from 'node-mocks-http' +// const PasswordChangeRequest = require('../../lib/requests/password-change-request') +// const SolidHost = require('../../lib/models/solid-host') +import PasswordChangeRequest from '../../lib/requests/password-change-request.js' +import SolidHost from '../../lib/models/solid-host.js' + +const { expect } = chai +chai.use(dirtyChai) +chai.use(sinonChai) +chai.should() + +describe('PasswordChangeRequest', () => { + sinon.spy(PasswordChangeRequest.prototype, 'error') + + describe('constructor()', () => { + it('should initialize a request instance from options', () => { + const res = HttpMocks.createResponse() + + const accountManager = {} + const userStore = {} + + const options = { + accountManager, + userStore, + returnToUrl: 'https://example.com/resource', + response: res, + token: '12345', + newPassword: 'swordfish' + } + + const request = new PasswordChangeRequest(options) + + expect(request.returnToUrl).to.equal(options.returnToUrl) + expect(request.response).to.equal(res) + expect(request.token).to.equal(options.token) + expect(request.newPassword).to.equal(options.newPassword) + expect(request.accountManager).to.equal(accountManager) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('fromParams()', () => { + it('should return a request instance from options', () => { + const returnToUrl = 'https://example.com/resource' + const token = '12345' + const newPassword = 'swordfish' + const accountManager = {} + const userStore = {} + + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token }, + body: { newPassword } + } + const res = HttpMocks.createResponse() + + const request = PasswordChangeRequest.fromParams(req, res) + + expect(request.returnToUrl).to.equal(returnToUrl) + expect(request.response).to.equal(res) + expect(request.token).to.equal(token) + expect(request.newPassword).to.equal(newPassword) + expect(request.accountManager).to.equal(accountManager) + expect(request.userStore).to.equal(userStore) + }) + }) + + describe('get()', () => { + const returnToUrl = 'https://example.com/resource' + const token = '12345' + const userStore = {} + const res = HttpMocks.createResponse() + sinon.spy(res, 'render') + + it('should create an instance and render a change password form', () => { + const accountManager = { + validateResetToken: sinon.stub().resolves(true) + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token } + } + + return PasswordChangeRequest.get(req, res) + .then(() => { + expect(accountManager.validateResetToken) + .to.have.been.called() + expect(res.render).to.have.been.calledWith('auth/change-password', + { returnToUrl, token, validToken: true }) + }) + }) + + it('should display an error message on an invalid token', () => { + const accountManager = { + validateResetToken: sinon.stub().throws() + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token } + } + + return PasswordChangeRequest.get(req, res) + .then(() => { + expect(PasswordChangeRequest.prototype.error) + .to.have.been.called() + }) + }) + }) + + describe('post()', () => { + it('creates a request instance and invokes handlePost()', () => { + sinon.spy(PasswordChangeRequest, 'handlePost') + + const returnToUrl = 'https://example.com/resource' + const token = '12345' + const newPassword = 'swordfish' + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const alice = { + webId: 'https://alice.example.com/#me' + } + const storedToken = { webId: alice.webId } + const store = { + findUser: sinon.stub().resolves(alice), + updatePassword: sinon.stub() + } + const accountManager = { + host, + store, + userAccountFrom: sinon.stub().resolves(alice), + validateResetToken: sinon.stub().resolves(storedToken) + } + + accountManager.accountExists = sinon.stub().resolves(true) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + + const req = { + app: { locals: { accountManager, oidc: { users: store } } }, + query: { returnToUrl }, + body: { token, newPassword } + } + const res = HttpMocks.createResponse() + + return PasswordChangeRequest.post(req, res) + .then(() => { + expect(PasswordChangeRequest.handlePost).to.have.been.called() + }) + }) + }) + + describe('handlePost()', () => { + it('should display error message if validation error encountered', () => { + const returnToUrl = 'https://example.com/resource' + const token = '12345' + const userStore = {} + const res = HttpMocks.createResponse() + const accountManager = { + validateResetToken: sinon.stub().throws() + } + const req = { + app: { locals: { accountManager, oidc: { users: userStore } } }, + query: { returnToUrl, token } + } + + const request = PasswordChangeRequest.fromParams(req, res) + + return PasswordChangeRequest.handlePost(request) + .then(() => { + expect(PasswordChangeRequest.prototype.error) + .to.have.been.called() + }) + }) + }) + + describe('validateToken()', () => { + it('should return false if no token is present', () => { + const accountManager = { + validateResetToken: sinon.stub() + } + const request = new PasswordChangeRequest({ accountManager, token: null }) + + return request.validateToken() + .then(result => { + expect(result).to.be.false() + expect(accountManager.validateResetToken).to.not.have.been.called() + }) + }) + }) + + describe('validatePost()', () => { + it('should throw an error if no new password was entered', () => { + const request = new PasswordChangeRequest({ newPassword: null }) + + expect(() => request.validatePost()).to.throw('Please enter a new password') + }) + }) + + describe('error()', () => { + it('should invoke renderForm() with the error', () => { + const request = new PasswordChangeRequest({}) + request.renderForm = sinon.stub() + const error = new Error('error message') + + request.error(error) + + expect(request.renderForm).to.have.been.calledWith(error) + }) + }) + + describe('changePassword()', () => { + it('should create a new user store entry if none exists', () => { + // this would be the case for legacy pre-user-store accounts + const webId = 'https://alice.example.com/#me' + const user = { webId, id: webId } + const accountManager = { + userAccountFrom: sinon.stub().returns(user) + } + const userStore = { + findUser: sinon.stub().resolves(null), // no user found + createUser: sinon.stub().resolves(), + updatePassword: sinon.stub().resolves() + } + + const options = { + accountManager, userStore, newPassword: 'swordfish' + } + const request = new PasswordChangeRequest(options) + + return request.changePassword(user) + .then(() => { + expect(userStore.createUser).to.have.been.calledWith(user, options.newPassword) + }) + }) + }) + + describe('renderForm()', () => { + it('should set response status to error status, if error exists', () => { + const returnToUrl = 'https://example.com/resource' + const token = '12345' + const response = HttpMocks.createResponse() + sinon.spy(response, 'render') + + const options = { returnToUrl, token, response } + + const request = new PasswordChangeRequest(options) + + const error = new Error('error message') + + request.renderForm(error) + + expect(response.render).to.have.been.calledWith('auth/change-password', + { validToken: false, token, returnToUrl, error: 'error message' }) + }) + }) +}) diff --git a/test/unit/password-reset-email-request-test.mjs b/test/unit/password-reset-email-request-test.js similarity index 95% rename from test/unit/password-reset-email-request-test.mjs rename to test/unit/password-reset-email-request-test.js index 05e4349b4..84adf6499 100644 --- a/test/unit/password-reset-email-request-test.mjs +++ b/test/unit/password-reset-email-request-test.js @@ -1,234 +1,234 @@ -import chai from 'chai' -import sinon from 'sinon' -import dirtyChai from 'dirty-chai' -import sinonChai from 'sinon-chai' -import HttpMocks from 'node-mocks-http' - -import PasswordResetEmailRequest from '../../lib/requests/password-reset-email-request.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' -import EmailService from '../../lib/services/email-service.mjs' - -const { expect } = chai -chai.use(dirtyChai) -chai.use(sinonChai) -chai.should() - -describe('PasswordResetEmailRequest', () => { - describe('constructor()', () => { - it('should initialize a request instance from options', () => { - const res = HttpMocks.createResponse() - - const options = { - returnToUrl: 'https://example.com/resource', - response: res, - username: 'alice' - } - - const request = new PasswordResetEmailRequest(options) - - expect(request.returnToUrl).to.equal(options.returnToUrl) - expect(request.response).to.equal(res) - expect(request.username).to.equal(options.username) - }) - }) - - describe('fromParams()', () => { - it('should return a request instance from options', () => { - const returnToUrl = 'https://example.com/resource' - const username = 'alice' - const accountManager = {} - - const req = { - app: { locals: { accountManager } }, - query: { returnToUrl }, - body: { username } - } - const res = HttpMocks.createResponse() - - const request = PasswordResetEmailRequest.fromParams(req, res) - - expect(request.accountManager).to.equal(accountManager) - expect(request.returnToUrl).to.equal(returnToUrl) - expect(request.username).to.equal(username) - expect(request.response).to.equal(res) - }) - }) - - describe('get()', () => { - it('should create an instance and render a reset password form', () => { - const returnToUrl = 'https://example.com/resource' - const username = 'alice' - const accountManager = { multiuser: true } - - const req = { - app: { locals: { accountManager } }, - query: { returnToUrl }, - body: { username } - } - const res = HttpMocks.createResponse() - res.render = sinon.stub() - - PasswordResetEmailRequest.get(req, res) - - expect(res.render).to.have.been.calledWith('auth/reset-password', - { returnToUrl, multiuser: true }) - }) - }) - - describe('post()', () => { - it('creates a request instance and invokes handlePost()', () => { - sinon.spy(PasswordResetEmailRequest, 'handlePost') - - const returnToUrl = 'https://example.com/resource' - const username = 'alice' - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { - suffixAcl: '.acl' - } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(true) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendPasswordResetEmail = sinon.stub().resolves() - - const req = { - app: { locals: { accountManager } }, - query: { returnToUrl }, - body: { username } - } - const res = HttpMocks.createResponse() - - PasswordResetEmailRequest.post(req, res) - .then(() => { - expect(PasswordResetEmailRequest.handlePost).to.have.been.called() - }) - }) - }) - - describe('validate()', () => { - it('should throw an error if username is missing in multi-user mode', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const accountManager = AccountManager.from({ host, multiuser: true }) - - const request = new PasswordResetEmailRequest({ accountManager }) - - expect(() => request.validate()).to.throw(/Username required/) - }) - - it('should not throw an error if username is missing in single user mode', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const accountManager = AccountManager.from({ host, multiuser: false }) - - const request = new PasswordResetEmailRequest({ accountManager }) - - expect(() => request.validate()).to.not.throw() - }) - }) - - describe('handlePost()', () => { - it('should handle the post request', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { suffixAcl: '.acl' } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendPasswordResetEmail = sinon.stub().resolves() - accountManager.accountExists = sinon.stub().resolves(true) - - const returnToUrl = 'https://example.com/resource' - const username = 'alice' - const response = HttpMocks.createResponse() - response.render = sinon.stub() - - const options = { accountManager, username, returnToUrl, response } - const request = new PasswordResetEmailRequest(options) - - sinon.spy(request, 'error') - - return PasswordResetEmailRequest.handlePost(request) - .then(() => { - expect(accountManager.loadAccountRecoveryEmail).to.have.been.called() - expect(accountManager.sendPasswordResetEmail).to.have.been.called() - expect(response.render).to.have.been.calledWith('auth/reset-link-sent') - expect(request.error).to.not.have.been.called() - }) - }) - - it('should hande a reset request with no username without privacy leakage', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { suffixAcl: '.acl' } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') - accountManager.sendPasswordResetEmail = sinon.stub().resolves() - accountManager.accountExists = sinon.stub().resolves(false) - - const returnToUrl = 'https://example.com/resource' - const username = 'alice' - const response = HttpMocks.createResponse() - response.render = sinon.stub() - - const options = { accountManager, username, returnToUrl, response } - const request = new PasswordResetEmailRequest(options) - - sinon.spy(request, 'error') - sinon.spy(request, 'validate') - sinon.spy(request, 'loadUser') - - return PasswordResetEmailRequest.handlePost(request) - .then(() => { - expect(request.validate).to.have.been.called() - expect(request.loadUser).to.have.been.called() - expect(request.loadUser).to.throw() - }).catch(() => { - expect(request.error).to.have.been.called() - expect(response.render).to.have.been.calledWith('auth/reset-link-sent') - expect(accountManager.loadAccountRecoveryEmail).to.not.have.been.called() - expect(accountManager.sendPasswordResetEmail).to.not.have.been.called() - }) - }) - }) - - describe('loadUser()', () => { - it('should return a UserAccount instance based on username', () => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { suffixAcl: '.acl' } - const accountManager = AccountManager.from({ host, multiuser: true, store }) - accountManager.accountExists = sinon.stub().resolves(true) - const username = 'alice' - - const options = { accountManager, username } - const request = new PasswordResetEmailRequest(options) - - return request.loadUser() - .then(account => { - expect(account.webId).to.equal('https://alice.example.com/profile/card#me') - }) - }) - - it('should throw an error if the user does not exist', done => { - const host = SolidHost.from({ serverUri: 'https://example.com' }) - const store = { suffixAcl: '.acl' } - const emailService = sinon.stub().returns(EmailService) - const accountManager = AccountManager.from({ host, multiuser: true, store, emailService }) - accountManager.accountExists = sinon.stub().resolves(false) - const username = 'alice' - const options = { accountManager, username } - const request = new PasswordResetEmailRequest(options) - - sinon.spy(request, 'resetLinkMessage') - sinon.spy(accountManager, 'userAccountFrom') - sinon.spy(accountManager, 'verifyEmailDependencies') - - request.loadUser() - .then(() => { - expect(accountManager.userAccountFrom).to.have.been.called() - expect(accountManager.verifyEmailDependencies).to.have.been.called() - expect(accountManager.verifyEmailDependencies).to.throw() - done() - }) - .catch(() => { - expect(request.resetLinkMessage).to.have.been.called() - done() - }) - }) - }) +import chai from 'chai' +import sinon from 'sinon' +import dirtyChai from 'dirty-chai' +import sinonChai from 'sinon-chai' +import HttpMocks from 'node-mocks-http' + +import PasswordResetEmailRequest from '../../lib/requests/password-reset-email-request.js' +import AccountManager from '../../lib/models/account-manager.js' +import SolidHost from '../../lib/models/solid-host.js' +import EmailService from '../../lib/services/email-service.js' + +const { expect } = chai +chai.use(dirtyChai) +chai.use(sinonChai) +chai.should() + +describe('PasswordResetEmailRequest', () => { + describe('constructor()', () => { + it('should initialize a request instance from options', () => { + const res = HttpMocks.createResponse() + + const options = { + returnToUrl: 'https://example.com/resource', + response: res, + username: 'alice' + } + + const request = new PasswordResetEmailRequest(options) + + expect(request.returnToUrl).to.equal(options.returnToUrl) + expect(request.response).to.equal(res) + expect(request.username).to.equal(options.username) + }) + }) + + describe('fromParams()', () => { + it('should return a request instance from options', () => { + const returnToUrl = 'https://example.com/resource' + const username = 'alice' + const accountManager = {} + + const req = { + app: { locals: { accountManager } }, + query: { returnToUrl }, + body: { username } + } + const res = HttpMocks.createResponse() + + const request = PasswordResetEmailRequest.fromParams(req, res) + + expect(request.accountManager).to.equal(accountManager) + expect(request.returnToUrl).to.equal(returnToUrl) + expect(request.username).to.equal(username) + expect(request.response).to.equal(res) + }) + }) + + describe('get()', () => { + it('should create an instance and render a reset password form', () => { + const returnToUrl = 'https://example.com/resource' + const username = 'alice' + const accountManager = { multiuser: true } + + const req = { + app: { locals: { accountManager } }, + query: { returnToUrl }, + body: { username } + } + const res = HttpMocks.createResponse() + res.render = sinon.stub() + + PasswordResetEmailRequest.get(req, res) + + expect(res.render).to.have.been.calledWith('auth/reset-password', + { returnToUrl, multiuser: true }) + }) + }) + + describe('post()', () => { + it('creates a request instance and invokes handlePost()', () => { + sinon.spy(PasswordResetEmailRequest, 'handlePost') + + const returnToUrl = 'https://example.com/resource' + const username = 'alice' + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { + suffixAcl: '.acl' + } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.accountExists = sinon.stub().resolves(true) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + + const req = { + app: { locals: { accountManager } }, + query: { returnToUrl }, + body: { username } + } + const res = HttpMocks.createResponse() + + PasswordResetEmailRequest.post(req, res) + .then(() => { + expect(PasswordResetEmailRequest.handlePost).to.have.been.called() + }) + }) + }) + + describe('validate()', () => { + it('should throw an error if username is missing in multi-user mode', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const accountManager = AccountManager.from({ host, multiuser: true }) + + const request = new PasswordResetEmailRequest({ accountManager }) + + expect(() => request.validate()).to.throw(/Username required/) + }) + + it('should not throw an error if username is missing in single user mode', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const accountManager = AccountManager.from({ host, multiuser: false }) + + const request = new PasswordResetEmailRequest({ accountManager }) + + expect(() => request.validate()).to.not.throw() + }) + }) + + describe('handlePost()', () => { + it('should handle the post request', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + accountManager.accountExists = sinon.stub().resolves(true) + + const returnToUrl = 'https://example.com/resource' + const username = 'alice' + const response = HttpMocks.createResponse() + response.render = sinon.stub() + + const options = { accountManager, username, returnToUrl, response } + const request = new PasswordResetEmailRequest(options) + + sinon.spy(request, 'error') + + return PasswordResetEmailRequest.handlePost(request) + .then(() => { + expect(accountManager.loadAccountRecoveryEmail).to.have.been.called() + expect(accountManager.sendPasswordResetEmail).to.have.been.called() + expect(response.render).to.have.been.calledWith('auth/reset-link-sent') + expect(request.error).to.not.have.been.called() + }) + }) + + it('should hande a reset request with no username without privacy leakage', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.loadAccountRecoveryEmail = sinon.stub().resolves('alice@example.com') + accountManager.sendPasswordResetEmail = sinon.stub().resolves() + accountManager.accountExists = sinon.stub().resolves(false) + + const returnToUrl = 'https://example.com/resource' + const username = 'alice' + const response = HttpMocks.createResponse() + response.render = sinon.stub() + + const options = { accountManager, username, returnToUrl, response } + const request = new PasswordResetEmailRequest(options) + + sinon.spy(request, 'error') + sinon.spy(request, 'validate') + sinon.spy(request, 'loadUser') + + return PasswordResetEmailRequest.handlePost(request) + .then(() => { + expect(request.validate).to.have.been.called() + expect(request.loadUser).to.have.been.called() + expect(request.loadUser).to.throw() + }).catch(() => { + expect(request.error).to.have.been.called() + expect(response.render).to.have.been.calledWith('auth/reset-link-sent') + expect(accountManager.loadAccountRecoveryEmail).to.not.have.been.called() + expect(accountManager.sendPasswordResetEmail).to.not.have.been.called() + }) + }) + }) + + describe('loadUser()', () => { + it('should return a UserAccount instance based on username', () => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const accountManager = AccountManager.from({ host, multiuser: true, store }) + accountManager.accountExists = sinon.stub().resolves(true) + const username = 'alice' + + const options = { accountManager, username } + const request = new PasswordResetEmailRequest(options) + + return request.loadUser() + .then(account => { + expect(account.webId).to.equal('https://alice.example.com/profile/card#me') + }) + }) + + it('should throw an error if the user does not exist', done => { + const host = SolidHost.from({ serverUri: 'https://example.com' }) + const store = { suffixAcl: '.acl' } + const emailService = sinon.stub().returns(EmailService) + const accountManager = AccountManager.from({ host, multiuser: true, store, emailService }) + accountManager.accountExists = sinon.stub().resolves(false) + const username = 'alice' + const options = { accountManager, username } + const request = new PasswordResetEmailRequest(options) + + sinon.spy(request, 'resetLinkMessage') + sinon.spy(accountManager, 'userAccountFrom') + sinon.spy(accountManager, 'verifyEmailDependencies') + + request.loadUser() + .then(() => { + expect(accountManager.userAccountFrom).to.have.been.called() + expect(accountManager.verifyEmailDependencies).to.have.been.called() + expect(accountManager.verifyEmailDependencies).to.throw() + done() + }) + .catch(() => { + expect(request.resetLinkMessage).to.have.been.called() + done() + }) + }) + }) }) diff --git a/test/unit/resource-mapper-test.mjs b/test/unit/resource-mapper-test.js similarity index 96% rename from test/unit/resource-mapper-test.mjs rename to test/unit/resource-mapper-test.js index 2669316d3..e4ba57366 100644 --- a/test/unit/resource-mapper-test.mjs +++ b/test/unit/resource-mapper-test.js @@ -1,673 +1,673 @@ -import { describe, it } from 'mocha' -import chai from 'chai' -import chaiAsPromised from 'chai-as-promised' - -// Import CommonJS modules -// const ResourceMapper = require('../../lib/resource-mapper') -import ResourceMapper from '../../lib/resource-mapper.mjs' -// import { createRequire } from 'module' - -// const require = createRequire(import.meta.url) -const { expect } = chai -chai.use(chaiAsPromised) - -const rootUrl = 'http://localhost/' -const rootPath = '/var/www/folder/' - -// Helper functions for testing -function asserter (fn) { - return function (mapper, label, ...args) { - return fn(it, mapper, label, ...args) - } -} - -function mapsUrl (it, mapper, label, options, files, expected) { - // Shift parameters if necessary - if (!expected) { - expected = files - files = undefined // No files array means don't mock filesystem - } - - // Mock filesystem only if files array is provided - function mockReaddir () { - if (files !== undefined) { - mapper._readdir = async (path) => { - // For the tests to work, we need to check if the path is in the expected range - expect(path.startsWith(rootPath)).to.equal(true) - - if (!files.length) { - // When empty files array is provided, simulate directory not found - throw new Error(`${path} Resource not found`) - } - - // Return just the filenames (not full paths) that are in the requested directory - // Normalize the path to handle different slash directions - const requestedDir = path.replace(/\\/g, '/') - - const matchingFiles = files - .filter(f => { - const normalizedFile = f.replace(/\\/g, '/') - const fileDir = normalizedFile.substring(0, normalizedFile.lastIndexOf('/') + 1) - return fileDir === requestedDir - }) - .map(f => { - const normalizedFile = f.replace(/\\/g, '/') - const filename = normalizedFile.substring(normalizedFile.lastIndexOf('/') + 1) - return filename - }) - .filter(f => f) // Only non-empty filenames - - return matchingFiles - } - } - // If no files array, don't mock - let it use real filesystem or default behavior - } - - // Set up positive test - if (!(expected instanceof Error)) { - it(`maps ${label}`, async () => { - mockReaddir() - const actual = await mapper.mapUrlToFile(options) - expect(actual).to.deep.equal(expected) - }) - // Set up error test - } else { - it(`does not map ${label}`, async () => { - mockReaddir() - const actual = mapper.mapUrlToFile(options) - await expect(actual).to.be.rejectedWith(expected.message) - }) - } -} - -function mapsFile (it, mapper, label, options, expected) { - it(`maps ${label}`, async () => { - const actual = await mapper.mapFileToUrl(options) - expect(actual).to.deep.equal(expected) - }) -} - -const itMapsUrl = asserter(mapsUrl) -const itMapsFile = asserter(mapsFile) - -describe('ResourceMapper', () => { - describe('A ResourceMapper instance for a single-host setup', () => { - const mapper = new ResourceMapper({ - rootUrl, - rootPath, - includeHost: false - }) - - // PUT base cases from https://www.w3.org/DesignIssues/HTTPFilenameMapping.html - - itMapsUrl(mapper, 'a URL with an extension that matches the content type', - { - url: 'http://localhost/space/%20foo .html', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}space/ foo .html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, "a URL with a bogus extension that doesn't match the content type", - { - url: 'http://localhost/space/foo.bar', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.bar$.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, "a URL with a real extension that doesn't match the content type", - { - url: 'http://localhost/space/foo.exe', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.exe$.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, "a URL that doesn't have an extension but should be saved as HTML", - { - url: 'http://localhost/space/foo', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo$.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL that already has the right extension', - { - url: 'http://localhost/space/foo.html', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.html`, - contentType: 'text/html' - }) - - // GET base cases - - itMapsUrl(mapper, 'a URL with a proper extension', - { - url: 'http://localhost/space/foo.html' - }, - [ - `${rootPath}space/foo.html` - ], - { - path: `${rootPath}space/foo.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, "a URL that doesn't have an extension", - { - url: 'http://localhost/space/foo' - }, - [ - `${rootPath}space/foo$.html`, - `${rootPath}space/foo$.json`, - `${rootPath}space/foo$.md`, - `${rootPath}space/foo$.rdf`, - `${rootPath}space/foo$.xml`, - `${rootPath}space/foo$.txt`, - `${rootPath}space/foo$.ttl`, - `${rootPath}space/foo$.jsonld`, - `${rootPath}space/foo` - ], - { - path: `${rootPath}space/foo$.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, "a URL that doesn't have an extension but has multiple possible files", - { - url: 'http://localhost/space/foo' - }, - [ - `${rootPath}space/foo$.html`, - `${rootPath}space/foo$.ttl` - ], - { - path: `${rootPath}space/foo$.html`, - contentType: 'text/html' - }) - - // Test with various content types - const contentTypes = [ - ['text/turtle', 'ttl'], - ['application/ld+json', 'jsonld'], - ['application/json', 'json'], - ['text/plain', 'txt'], - ['text/markdown', 'md'], - ['application/rdf+xml', 'rdf'], - ['application/xml', 'xml'] - ] - - contentTypes.forEach(([contentType, extension]) => { - itMapsUrl(mapper, `a URL for ${contentType}`, - { - url: `http://localhost/space/foo.${extension}`, - contentType, - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.${extension}`, - contentType - }) - }) - - // Directory mapping tests - itMapsUrl(mapper, 'a directory URL', - { - url: 'http://localhost/space/' - }, - [ - `${rootPath}space/index.html` - ], - { - path: `${rootPath}space/index.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'the root directory URL', - { - url: 'http://localhost/' - }, - [ - `${rootPath}index.html` - ], - { - path: `${rootPath}index.html`, - contentType: 'text/html' - }) - - // Test file to URL mapping - itMapsFile(mapper, 'a regular file path', - { - path: `${rootPath}space/foo.html`, - hostname: 'localhost' - }, - { - url: 'http://localhost/space/foo.html', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a directory path', - { - path: `${rootPath}space/`, - hostname: 'localhost' - }, - { - url: 'http://localhost/space/', - contentType: 'text/turtle' - }) - // --- Additional error and edge-case tests for full parity --- - itMapsUrl(mapper, 'a URL without content type', - { - url: 'http://localhost/space/foo.html', - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.html$.unknown`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL with an unknown content type', - { - url: 'http://localhost/space/foo.html', - contentTypes: ['text/unknown'], - createIfNotExists: true - }, - { - path: `${rootPath}space/foo.html$.unknown`, - contentType: 'application/octet-stream' - }) - - itMapsUrl(mapper, 'a URL with a /.. path segment', - { - url: 'http://localhost/space/../bar' - }, - new Error('Disallowed /.. segment in URL')) - - itMapsUrl(mapper, 'a URL ending with a slash for text/turtle', - { - url: 'http://localhost/space/', - contentType: 'text/turtle', - createIfNotExists: true - }, - new Error('Index file needs to have text/html as content type')) - - itMapsUrl(mapper, 'a URL of a non-existent folder', - { - url: 'http://localhost/space/foo/' - }, - [], - new Error('/space/foo/ Resource not found')) - - itMapsUrl(mapper, 'a URL of a non-existent file', - { - url: 'http://localhost/space/foo.html' - }, - [], - new Error('/space/foo.html Resource not found')) - - itMapsUrl(mapper, 'a URL of an existing .acl file', - { - url: 'http://localhost/space/.acl' - }, - [ - `${rootPath}space/.acl` - ], - { - path: `${rootPath}space/.acl`, - contentType: 'text/turtle' - }) - - itMapsUrl(mapper, 'a URL of an existing .acl file with a different content type', - { - url: 'http://localhost/space/.acl' - }, - [ - `${rootPath}space/.acl$.n3` - ], - { - path: `${rootPath}space/.acl$.n3`, - contentType: 'text/n3' - }) - - itMapsUrl(mapper, 'an extensionless URL of an existing file, with multiple choices', - { - url: 'http://localhost/space/foo' - }, - [ - `${rootPath}space/foo$.html`, - `${rootPath}space/foo$.ttl`, - `${rootPath}space/foo$.png` - ], - { - path: `${rootPath}space/foo$.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'an extensionless URL of an existing file with an uppercase extension', - { - url: 'http://localhost/space/foo' - }, - [ - `${rootPath}space/foo$.HTML` - ], - { - path: `${rootPath}space/foo$.HTML`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'an extensionless URL of an existing file with a mixed-case extension', - { - url: 'http://localhost/space/foo' - }, - [ - `${rootPath}space/foo$.HtMl` - ], - { - path: `${rootPath}space/foo$.HtMl`, - contentType: 'text/html' - }) - itMapsFile(mapper, 'an unknown file type', - { path: `${rootPath}space/foo.bar` }, - { - url: 'http://localhost/space/foo.bar', - contentType: 'application/octet-stream' - }) - - itMapsFile(mapper, 'a file with an uppercase extension', - { path: `${rootPath}space/foo.HTML` }, - { - url: 'http://localhost/space/foo.HTML', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a file with a mixed-case extension', - { path: `${rootPath}space/foo.HtMl` }, - { - url: 'http://localhost/space/foo.HtMl', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'an extensionless HTML file', - { path: `${rootPath}space/foo$.html` }, - { - url: 'http://localhost/space/foo', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'an extensionless Turtle file', - { path: `${rootPath}space/foo$.ttl` }, - { - url: 'http://localhost/space/foo', - contentType: 'text/turtle' - }) - - itMapsFile(mapper, 'an extensionless unknown file type', - { path: `${rootPath}space/%2ffoo%2F$.bar` }, - { - url: 'http://localhost/space/%2ffoo%2F', - contentType: 'application/octet-stream' - }) - - itMapsFile(mapper, 'an extensionless file with an uppercase extension', - { path: `${rootPath}space/foo$.HTML` }, - { - url: 'http://localhost/space/foo', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'an extensionless file with a mixed-case extension', - { path: `${rootPath}space/foo$.HtMl` }, - { - url: 'http://localhost/space/foo', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a file with disallowed IRI characters', - { path: `${rootPath}space/foo bar bar.html` }, - { - url: 'http://localhost/space/foo%20bar%20bar.html', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a file with %encoded /', - { path: `${rootPath}%2Fspace/%25252Ffoo%2f.html` }, - { - url: 'http://localhost/%2Fspace/%25252Ffoo%2f.html', - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a file with even stranger disallowed IRI characters', - { path: `${rootPath}%2fspace%2F/Blog discovery for the future? · Issue #96 · scripting:Scripting-News · GitHub.pdf` }, - { - url: 'http://localhost/%2fspace%2F/Blog%20discovery%20for%20the%20future%3F%20%C2%B7%20Issue%20%2396%20%C2%B7%20scripting%3AScripting-News%20%C2%B7%20GitHub.pdf', - contentType: 'application/pdf' - }) - }) - - describe('A ResourceMapper instance for a multi-host setup', () => { - const mapper = new ResourceMapper({ - rootUrl, - rootPath, - includeHost: true - }) - - itMapsUrl(mapper, 'a URL with host in path', - { - url: 'http://example.org/space/foo.html' - }, - [ - `${rootPath}example.org/space/foo.html` - ], - { - path: `${rootPath}example.org/space/foo.html`, - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a file path with host directory', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'http://example.org/space/foo.html', - contentType: 'text/html' - }) - itMapsUrl(mapper, 'a URL with a host', - { - url: 'http://example.org/space/foo.html', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}example.org/space/foo.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL with a host specified as a URL object', - { - url: { - hostname: 'example.org', - path: '/space/foo.html' - }, - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}example.org/space/foo.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL with a host specified as an Express request object', - { - url: { - hostname: 'example.org', - pathname: '/space/foo.html' - }, - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}example.org/space/foo.html`, - contentType: 'text/html' - }) - - itMapsUrl(mapper, 'a URL with a host with a port', - { - url: 'http://example.org:3000/space/foo.html', - contentType: 'text/html', - createIfNotExists: true - }, - { - path: `${rootPath}example.org/space/foo.html`, - contentType: 'text/html' - }) - - itMapsFile(mapper, 'a file on a host', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'http://example.org/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for a multi-host setup with a subfolder root URL', () => { - const rootUrl = 'https://localhost/foo/bar/' - const mapper = new ResourceMapper({ rootUrl, rootPath, includeHost: true }) - - itMapsFile(mapper, 'a file on a host', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'https://example.org/foo/bar/space/foo.html', - contentType: 'text/html' - }) - describe('A ResourceMapper instance for an HTTP host with non-default port', () => { - const mapper = new ResourceMapper({ rootUrl: 'http://localhost:81/', rootPath }) - - itMapsFile(mapper, 'a file with the port', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'http://localhost:81/example.org/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for an HTTP host with non-default port in a multi-host setup', () => { - const mapper = new ResourceMapper({ rootUrl: 'http://localhost:81/', rootPath, includeHost: true }) - - itMapsFile(mapper, 'a file with the port', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'http://example.org:81/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for an HTTPS host with non-default port', () => { - const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath }) - - itMapsFile(mapper, 'a file with the port', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'https://localhost:81/example.org/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for an HTTPS host with non-default port in a multi-host setup', () => { - const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath, includeHost: true }) - - itMapsFile(mapper, 'a file with the port', - { - path: `${rootPath}example.org/space/foo.html`, - hostname: 'example.org' - }, - { - url: 'https://example.org:81/space/foo.html', - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for an HTTPS host with non-default port in a multi-host setup', () => { - const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath, includeHost: true }) - - it('throws an error when there is an improper file path', () => { - return expect(mapper.mapFileToUrl({ - path: `${rootPath}example.orgspace/foo.html`, - hostname: 'example.org' - })).to.be.rejectedWith(Error, 'Path must start with hostname (/example.org)') - }) - }) - }) - - // Additional test cases for various port configurations - describe('A ResourceMapper instance for an HTTP host with non-default port', () => { - const mapper = new ResourceMapper({ - rootUrl: 'http://localhost:8080/', - rootPath, - includeHost: false - }) - - itMapsUrl(mapper, 'a URL with non-default HTTP port', - { - url: 'http://localhost:8080/space/foo.html' - }, - [ - `${rootPath}space/foo.html` - ], - { - path: `${rootPath}space/foo.html`, - contentType: 'text/html' - }) - }) - - describe('A ResourceMapper instance for an HTTPS host with non-default port', () => { - const mapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - rootPath, - includeHost: false - }) - - itMapsUrl(mapper, 'a URL with non-default HTTPS port', - { - url: 'https://localhost:8443/space/foo.html' - }, - [ - `${rootPath}space/foo.html` - ], - { - path: `${rootPath}space/foo.html`, - contentType: 'text/html' - }) - }) +import { describe, it } from 'mocha' +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' + +// Import CommonJS modules +// const ResourceMapper = require('../../lib/resource-mapper') +import ResourceMapper from '../../lib/resource-mapper.js' +// import { createRequire } from 'module' + +// const require = createRequire(import.meta.url) +const { expect } = chai +chai.use(chaiAsPromised) + +const rootUrl = 'http://localhost/' +const rootPath = '/var/www/folder/' + +// Helper functions for testing +function asserter (fn) { + return function (mapper, label, ...args) { + return fn(it, mapper, label, ...args) + } +} + +function mapsUrl (it, mapper, label, options, files, expected) { + // Shift parameters if necessary + if (!expected) { + expected = files + files = undefined // No files array means don't mock filesystem + } + + // Mock filesystem only if files array is provided + function mockReaddir () { + if (files !== undefined) { + mapper._readdir = async (path) => { + // For the tests to work, we need to check if the path is in the expected range + expect(path.startsWith(rootPath)).to.equal(true) + + if (!files.length) { + // When empty files array is provided, simulate directory not found + throw new Error(`${path} Resource not found`) + } + + // Return just the filenames (not full paths) that are in the requested directory + // Normalize the path to handle different slash directions + const requestedDir = path.replace(/\\/g, '/') + + const matchingFiles = files + .filter(f => { + const normalizedFile = f.replace(/\\/g, '/') + const fileDir = normalizedFile.substring(0, normalizedFile.lastIndexOf('/') + 1) + return fileDir === requestedDir + }) + .map(f => { + const normalizedFile = f.replace(/\\/g, '/') + const filename = normalizedFile.substring(normalizedFile.lastIndexOf('/') + 1) + return filename + }) + .filter(f => f) // Only non-empty filenames + + return matchingFiles + } + } + // If no files array, don't mock - let it use real filesystem or default behavior + } + + // Set up positive test + if (!(expected instanceof Error)) { + it(`maps ${label}`, async () => { + mockReaddir() + const actual = await mapper.mapUrlToFile(options) + expect(actual).to.deep.equal(expected) + }) + // Set up error test + } else { + it(`does not map ${label}`, async () => { + mockReaddir() + const actual = mapper.mapUrlToFile(options) + await expect(actual).to.be.rejectedWith(expected.message) + }) + } +} + +function mapsFile (it, mapper, label, options, expected) { + it(`maps ${label}`, async () => { + const actual = await mapper.mapFileToUrl(options) + expect(actual).to.deep.equal(expected) + }) +} + +const itMapsUrl = asserter(mapsUrl) +const itMapsFile = asserter(mapsFile) + +describe('ResourceMapper', () => { + describe('A ResourceMapper instance for a single-host setup', () => { + const mapper = new ResourceMapper({ + rootUrl, + rootPath, + includeHost: false + }) + + // PUT base cases from https://www.w3.org/DesignIssues/HTTPFilenameMapping.html + + itMapsUrl(mapper, 'a URL with an extension that matches the content type', + { + url: 'http://localhost/space/%20foo .html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/ foo .html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL with a bogus extension that doesn't match the content type", + { + url: 'http://localhost/space/foo.bar', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.bar$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL with a real extension that doesn't match the content type", + { + url: 'http://localhost/space/foo.exe', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.exe$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL that doesn't have an extension but should be saved as HTML", + { + url: 'http://localhost/space/foo', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'a URL that already has the right extension', + { + url: 'http://localhost/space/foo.html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.html`, + contentType: 'text/html' + }) + + // GET base cases + + itMapsUrl(mapper, 'a URL with a proper extension', + { + url: 'http://localhost/space/foo.html' + }, + [ + `${rootPath}space/foo.html` + ], + { + path: `${rootPath}space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL that doesn't have an extension", + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.html`, + `${rootPath}space/foo$.json`, + `${rootPath}space/foo$.md`, + `${rootPath}space/foo$.rdf`, + `${rootPath}space/foo$.xml`, + `${rootPath}space/foo$.txt`, + `${rootPath}space/foo$.ttl`, + `${rootPath}space/foo$.jsonld`, + `${rootPath}space/foo` + ], + { + path: `${rootPath}space/foo$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, "a URL that doesn't have an extension but has multiple possible files", + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.html`, + `${rootPath}space/foo$.ttl` + ], + { + path: `${rootPath}space/foo$.html`, + contentType: 'text/html' + }) + + // Test with various content types + const contentTypes = [ + ['text/turtle', 'ttl'], + ['application/ld+json', 'jsonld'], + ['application/json', 'json'], + ['text/plain', 'txt'], + ['text/markdown', 'md'], + ['application/rdf+xml', 'rdf'], + ['application/xml', 'xml'] + ] + + contentTypes.forEach(([contentType, extension]) => { + itMapsUrl(mapper, `a URL for ${contentType}`, + { + url: `http://localhost/space/foo.${extension}`, + contentType, + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.${extension}`, + contentType + }) + }) + + // Directory mapping tests + itMapsUrl(mapper, 'a directory URL', + { + url: 'http://localhost/space/' + }, + [ + `${rootPath}space/index.html` + ], + { + path: `${rootPath}space/index.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'the root directory URL', + { + url: 'http://localhost/' + }, + [ + `${rootPath}index.html` + ], + { + path: `${rootPath}index.html`, + contentType: 'text/html' + }) + + // Test file to URL mapping + itMapsFile(mapper, 'a regular file path', + { + path: `${rootPath}space/foo.html`, + hostname: 'localhost' + }, + { + url: 'http://localhost/space/foo.html', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a directory path', + { + path: `${rootPath}space/`, + hostname: 'localhost' + }, + { + url: 'http://localhost/space/', + contentType: 'text/turtle' + }) + // --- Additional error and edge-case tests for full parity --- + itMapsUrl(mapper, 'a URL without content type', + { + url: 'http://localhost/space/foo.html', + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.html$.unknown`, + contentType: 'application/octet-stream' + }) + + itMapsUrl(mapper, 'a URL with an unknown content type', + { + url: 'http://localhost/space/foo.html', + contentTypes: ['text/unknown'], + createIfNotExists: true + }, + { + path: `${rootPath}space/foo.html$.unknown`, + contentType: 'application/octet-stream' + }) + + itMapsUrl(mapper, 'a URL with a /.. path segment', + { + url: 'http://localhost/space/../bar' + }, + new Error('Disallowed /.. segment in URL')) + + itMapsUrl(mapper, 'a URL ending with a slash for text/turtle', + { + url: 'http://localhost/space/', + contentType: 'text/turtle', + createIfNotExists: true + }, + new Error('Index file needs to have text/html as content type')) + + itMapsUrl(mapper, 'a URL of a non-existent folder', + { + url: 'http://localhost/space/foo/' + }, + [], + new Error('/space/foo/ Resource not found')) + + itMapsUrl(mapper, 'a URL of a non-existent file', + { + url: 'http://localhost/space/foo.html' + }, + [], + new Error('/space/foo.html Resource not found')) + + itMapsUrl(mapper, 'a URL of an existing .acl file', + { + url: 'http://localhost/space/.acl' + }, + [ + `${rootPath}space/.acl` + ], + { + path: `${rootPath}space/.acl`, + contentType: 'text/turtle' + }) + + itMapsUrl(mapper, 'a URL of an existing .acl file with a different content type', + { + url: 'http://localhost/space/.acl' + }, + [ + `${rootPath}space/.acl$.n3` + ], + { + path: `${rootPath}space/.acl$.n3`, + contentType: 'text/n3' + }) + + itMapsUrl(mapper, 'an extensionless URL of an existing file, with multiple choices', + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.html`, + `${rootPath}space/foo$.ttl`, + `${rootPath}space/foo$.png` + ], + { + path: `${rootPath}space/foo$.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'an extensionless URL of an existing file with an uppercase extension', + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.HTML` + ], + { + path: `${rootPath}space/foo$.HTML`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'an extensionless URL of an existing file with a mixed-case extension', + { + url: 'http://localhost/space/foo' + }, + [ + `${rootPath}space/foo$.HtMl` + ], + { + path: `${rootPath}space/foo$.HtMl`, + contentType: 'text/html' + }) + itMapsFile(mapper, 'an unknown file type', + { path: `${rootPath}space/foo.bar` }, + { + url: 'http://localhost/space/foo.bar', + contentType: 'application/octet-stream' + }) + + itMapsFile(mapper, 'a file with an uppercase extension', + { path: `${rootPath}space/foo.HTML` }, + { + url: 'http://localhost/space/foo.HTML', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file with a mixed-case extension', + { path: `${rootPath}space/foo.HtMl` }, + { + url: 'http://localhost/space/foo.HtMl', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'an extensionless HTML file', + { path: `${rootPath}space/foo$.html` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'an extensionless Turtle file', + { path: `${rootPath}space/foo$.ttl` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/turtle' + }) + + itMapsFile(mapper, 'an extensionless unknown file type', + { path: `${rootPath}space/%2ffoo%2F$.bar` }, + { + url: 'http://localhost/space/%2ffoo%2F', + contentType: 'application/octet-stream' + }) + + itMapsFile(mapper, 'an extensionless file with an uppercase extension', + { path: `${rootPath}space/foo$.HTML` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'an extensionless file with a mixed-case extension', + { path: `${rootPath}space/foo$.HtMl` }, + { + url: 'http://localhost/space/foo', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file with disallowed IRI characters', + { path: `${rootPath}space/foo bar bar.html` }, + { + url: 'http://localhost/space/foo%20bar%20bar.html', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file with %encoded /', + { path: `${rootPath}%2Fspace/%25252Ffoo%2f.html` }, + { + url: 'http://localhost/%2Fspace/%25252Ffoo%2f.html', + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file with even stranger disallowed IRI characters', + { path: `${rootPath}%2fspace%2F/Blog discovery for the future? · Issue #96 · scripting:Scripting-News · GitHub.pdf` }, + { + url: 'http://localhost/%2fspace%2F/Blog%20discovery%20for%20the%20future%3F%20%C2%B7%20Issue%20%2396%20%C2%B7%20scripting%3AScripting-News%20%C2%B7%20GitHub.pdf', + contentType: 'application/pdf' + }) + }) + + describe('A ResourceMapper instance for a multi-host setup', () => { + const mapper = new ResourceMapper({ + rootUrl, + rootPath, + includeHost: true + }) + + itMapsUrl(mapper, 'a URL with host in path', + { + url: 'http://example.org/space/foo.html' + }, + [ + `${rootPath}example.org/space/foo.html` + ], + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file path with host directory', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'http://example.org/space/foo.html', + contentType: 'text/html' + }) + itMapsUrl(mapper, 'a URL with a host', + { + url: 'http://example.org/space/foo.html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'a URL with a host specified as a URL object', + { + url: { + hostname: 'example.org', + path: '/space/foo.html' + }, + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'a URL with a host specified as an Express request object', + { + url: { + hostname: 'example.org', + pathname: '/space/foo.html' + }, + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsUrl(mapper, 'a URL with a host with a port', + { + url: 'http://example.org:3000/space/foo.html', + contentType: 'text/html', + createIfNotExists: true + }, + { + path: `${rootPath}example.org/space/foo.html`, + contentType: 'text/html' + }) + + itMapsFile(mapper, 'a file on a host', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'http://example.org/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for a multi-host setup with a subfolder root URL', () => { + const rootUrl = 'https://localhost/foo/bar/' + const mapper = new ResourceMapper({ rootUrl, rootPath, includeHost: true }) + + itMapsFile(mapper, 'a file on a host', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'https://example.org/foo/bar/space/foo.html', + contentType: 'text/html' + }) + describe('A ResourceMapper instance for an HTTP host with non-default port', () => { + const mapper = new ResourceMapper({ rootUrl: 'http://localhost:81/', rootPath }) + + itMapsFile(mapper, 'a file with the port', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'http://localhost:81/example.org/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for an HTTP host with non-default port in a multi-host setup', () => { + const mapper = new ResourceMapper({ rootUrl: 'http://localhost:81/', rootPath, includeHost: true }) + + itMapsFile(mapper, 'a file with the port', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'http://example.org:81/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for an HTTPS host with non-default port', () => { + const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath }) + + itMapsFile(mapper, 'a file with the port', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'https://localhost:81/example.org/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for an HTTPS host with non-default port in a multi-host setup', () => { + const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath, includeHost: true }) + + itMapsFile(mapper, 'a file with the port', + { + path: `${rootPath}example.org/space/foo.html`, + hostname: 'example.org' + }, + { + url: 'https://example.org:81/space/foo.html', + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for an HTTPS host with non-default port in a multi-host setup', () => { + const mapper = new ResourceMapper({ rootUrl: 'https://localhost:81/', rootPath, includeHost: true }) + + it('throws an error when there is an improper file path', () => { + return expect(mapper.mapFileToUrl({ + path: `${rootPath}example.orgspace/foo.html`, + hostname: 'example.org' + })).to.be.rejectedWith(Error, 'Path must start with hostname (/example.org)') + }) + }) + }) + + // Additional test cases for various port configurations + describe('A ResourceMapper instance for an HTTP host with non-default port', () => { + const mapper = new ResourceMapper({ + rootUrl: 'http://localhost:8080/', + rootPath, + includeHost: false + }) + + itMapsUrl(mapper, 'a URL with non-default HTTP port', + { + url: 'http://localhost:8080/space/foo.html' + }, + [ + `${rootPath}space/foo.html` + ], + { + path: `${rootPath}space/foo.html`, + contentType: 'text/html' + }) + }) + + describe('A ResourceMapper instance for an HTTPS host with non-default port', () => { + const mapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + rootPath, + includeHost: false + }) + + itMapsUrl(mapper, 'a URL with non-default HTTPS port', + { + url: 'https://localhost:8443/space/foo.html' + }, + [ + `${rootPath}space/foo.html` + ], + { + path: `${rootPath}space/foo.html`, + contentType: 'text/html' + }) + }) }) diff --git a/test/unit/solid-host-test.mjs b/test/unit/solid-host-test.js similarity index 94% rename from test/unit/solid-host-test.mjs rename to test/unit/solid-host-test.js index 1a7312ce6..2e9d4ec2c 100644 --- a/test/unit/solid-host-test.mjs +++ b/test/unit/solid-host-test.js @@ -1,118 +1,118 @@ -import { describe, it, before } from 'mocha' -import { expect } from 'chai' -import SolidHost from '../../lib/models/solid-host.mjs' -import defaults from '../../config/defaults.mjs' - -describe('SolidHost', () => { - describe('from()', () => { - it('should init with provided params', () => { - const config = { - port: 3000, - serverUri: 'https://localhost:3000', - live: true, - root: '/data/solid/', - multiuser: true, - webid: true - } - const host = SolidHost.from(config) - - expect(host.port).to.equal(3000) - expect(host.serverUri).to.equal('https://localhost:3000') - expect(host.hostname).to.equal('localhost') - expect(host.live).to.be.true - expect(host.root).to.equal('/data/solid/') - expect(host.multiuser).to.be.true - expect(host.webid).to.be.true - }) - - it('should init to default port and serverUri values', () => { - const host = SolidHost.from({}) - expect(host.port).to.equal(defaults.port) - expect(host.serverUri).to.equal(defaults.serverUri) - }) - }) - - describe('accountUriFor()', () => { - it('should compose an account uri for an account name', () => { - const config = { - serverUri: 'https://test.local' - } - const host = SolidHost.from(config) - - expect(host.accountUriFor('alice')).to.equal('https://alice.test.local') - }) - - it('should throw an error if no account name is passed in', () => { - const host = SolidHost.from() - expect(() => { host.accountUriFor() }).to.throw(TypeError) - }) - }) - - describe('allowsSessionFor()', () => { - let host - before(() => { - host = SolidHost.from({ - serverUri: 'https://test.local' - }) - }) - - it('should allow an empty userId and origin', () => { - expect(host.allowsSessionFor('', '', [])).to.be.true - }) - - it('should allow a userId with empty origin', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', '', [])).to.be.true - }) - - it('should allow a userId with the user subdomain as origin', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://user.own', [])).to.be.true - }) - - it('should allow a userId with the server domain as origin', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://test.local', [])).to.be.true - }) - - it('should allow a userId with a server subdomain as origin', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.test.local', [])).to.be.true - }) - - it('should disallow a userId from a different domain', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.remote', [])).to.be.false - }) - - it('should allow user from a trusted domain', () => { - expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.remote', ['https://other.remote'])).to.be.true - }) - }) - - describe('cookieDomain getter', () => { - it('should return null for single-part domains (localhost)', () => { - const host = SolidHost.from({ - serverUri: 'https://localhost:8443' - }) - - expect(host.cookieDomain).to.be.null - }) - - it('should return a cookie domain for multi-part domains', () => { - const host = SolidHost.from({ - serverUri: 'https://example.com:8443' - }) - - expect(host.cookieDomain).to.equal('.example.com') - }) - }) - - describe('authEndpoint getter', () => { - it('should return an /authorize url object', () => { - const host = SolidHost.from({ - serverUri: 'https://localhost:8443' - }) - - const authUrl = host.authEndpoint - - expect(authUrl.host).to.equal('localhost:8443') - expect(authUrl.pathname).to.equal('/authorize') - }) - }) -}) +import { describe, it, before } from 'mocha' +import { expect } from 'chai' +import SolidHost from '../../lib/models/solid-host.js' +import defaults from '../../config/defaults.js' + +describe('SolidHost', () => { + describe('from()', () => { + it('should init with provided params', () => { + const config = { + port: 3000, + serverUri: 'https://localhost:3000', + live: true, + root: '/data/solid/', + multiuser: true, + webid: true + } + const host = SolidHost.from(config) + + expect(host.port).to.equal(3000) + expect(host.serverUri).to.equal('https://localhost:3000') + expect(host.hostname).to.equal('localhost') + expect(host.live).to.be.true + expect(host.root).to.equal('/data/solid/') + expect(host.multiuser).to.be.true + expect(host.webid).to.be.true + }) + + it('should init to default port and serverUri values', () => { + const host = SolidHost.from({}) + expect(host.port).to.equal(defaults.port) + expect(host.serverUri).to.equal(defaults.serverUri) + }) + }) + + describe('accountUriFor()', () => { + it('should compose an account uri for an account name', () => { + const config = { + serverUri: 'https://test.local' + } + const host = SolidHost.from(config) + + expect(host.accountUriFor('alice')).to.equal('https://alice.test.local') + }) + + it('should throw an error if no account name is passed in', () => { + const host = SolidHost.from() + expect(() => { host.accountUriFor() }).to.throw(TypeError) + }) + }) + + describe('allowsSessionFor()', () => { + let host + before(() => { + host = SolidHost.from({ + serverUri: 'https://test.local' + }) + }) + + it('should allow an empty userId and origin', () => { + expect(host.allowsSessionFor('', '', [])).to.be.true + }) + + it('should allow a userId with empty origin', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', '', [])).to.be.true + }) + + it('should allow a userId with the user subdomain as origin', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://user.own', [])).to.be.true + }) + + it('should allow a userId with the server domain as origin', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://test.local', [])).to.be.true + }) + + it('should allow a userId with a server subdomain as origin', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.test.local', [])).to.be.true + }) + + it('should disallow a userId from a different domain', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.remote', [])).to.be.false + }) + + it('should allow user from a trusted domain', () => { + expect(host.allowsSessionFor('https://user.own/profile/card#me', 'https://other.remote', ['https://other.remote'])).to.be.true + }) + }) + + describe('cookieDomain getter', () => { + it('should return null for single-part domains (localhost)', () => { + const host = SolidHost.from({ + serverUri: 'https://localhost:8443' + }) + + expect(host.cookieDomain).to.be.null + }) + + it('should return a cookie domain for multi-part domains', () => { + const host = SolidHost.from({ + serverUri: 'https://example.com:8443' + }) + + expect(host.cookieDomain).to.equal('.example.com') + }) + }) + + describe('authEndpoint getter', () => { + it('should return an /authorize url object', () => { + const host = SolidHost.from({ + serverUri: 'https://localhost:8443' + }) + + const authUrl = host.authEndpoint + + expect(authUrl.host).to.equal('localhost:8443') + expect(authUrl.pathname).to.equal('/authorize') + }) + }) +}) diff --git a/test/unit/tls-authenticator-test.mjs b/test/unit/tls-authenticator-test.js similarity index 94% rename from test/unit/tls-authenticator-test.mjs rename to test/unit/tls-authenticator-test.js index 06c5acacb..6780a3660 100644 --- a/test/unit/tls-authenticator-test.mjs +++ b/test/unit/tls-authenticator-test.js @@ -1,174 +1,174 @@ -import chai from 'chai' -import sinon from 'sinon' -import sinonChai from 'sinon-chai' -import dirtyChai from 'dirty-chai' -import chaiAsPromised from 'chai-as-promised' - -import { TlsAuthenticator } from '../../lib/models/authenticator.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' - -const { expect } = chai -chai.use(sinonChai) -chai.use(dirtyChai) -chai.use(chaiAsPromised) -chai.should() - -const host = SolidHost.from({ serverUri: 'https://example.com' }) -const accountManager = AccountManager.from({ host, multiuser: true }) - -describe('TlsAuthenticator', () => { - describe('fromParams()', () => { - const req = { - connection: {} - } - const options = { accountManager } - - it('should return a TlsAuthenticator instance', () => { - const tlsAuth = TlsAuthenticator.fromParams(req, options) - - expect(tlsAuth.accountManager).to.equal(accountManager) - expect(tlsAuth.connection).to.equal(req.connection) - }) - }) - - describe('findValidUser()', () => { - const webId = 'https://alice.example.com/#me' - const certificate = { uri: webId } - const connection = { - renegotiate: sinon.stub().yields(), - getPeerCertificate: sinon.stub().returns(certificate) - } - const options = { accountManager, connection } - - const tlsAuth = new TlsAuthenticator(options) - - tlsAuth.extractWebId = sinon.stub().resolves(webId) - sinon.spy(tlsAuth, 'renegotiateTls') - sinon.spy(tlsAuth, 'loadUser') - - return tlsAuth.findValidUser() - .then(validUser => { - expect(tlsAuth.renegotiateTls).to.have.been.called() - expect(connection.getPeerCertificate).to.have.been.called() - expect(tlsAuth.extractWebId).to.have.been.calledWith(certificate) - expect(tlsAuth.loadUser).to.have.been.calledWith(webId) - - expect(validUser.webId).to.equal(webId) - }) - }) - - describe('renegotiateTls()', () => { - it('should reject if an error occurs while renegotiating', () => { - const connection = { - renegotiate: sinon.stub().yields(new Error('Error renegotiating')) - } - - const tlsAuth = new TlsAuthenticator({ connection }) - - expect(tlsAuth.renegotiateTls()).to.be.rejectedWith(/Error renegotiating/) - }) - - it('should resolve if no error occurs', () => { - const connection = { - renegotiate: sinon.stub().yields(null) - } - - const tlsAuth = new TlsAuthenticator({ connection }) - - expect(tlsAuth.renegotiateTls()).to.be.fulfilled() - }) - }) - - describe('getCertificate()', () => { - it('should throw on a non-existent certificate', () => { - const connection = { - getPeerCertificate: sinon.stub().returns(null) - } - - const tlsAuth = new TlsAuthenticator({ connection }) - - expect(() => tlsAuth.getCertificate()).to.throw(/No client certificate detected/) - }) - - it('should throw on an empty certificate', () => { - const connection = { - getPeerCertificate: sinon.stub().returns({}) - } - - const tlsAuth = new TlsAuthenticator({ connection }) - - expect(() => tlsAuth.getCertificate()).to.throw(/No client certificate detected/) - }) - - it('should return a certificate if no error occurs', () => { - const certificate = { uri: 'https://alice.example.com/#me' } - const connection = { - getPeerCertificate: sinon.stub().returns(certificate) - } - - const tlsAuth = new TlsAuthenticator({ connection }) - - expect(tlsAuth.getCertificate()).to.equal(certificate) - }) - }) - - describe('extractWebId()', () => { - it('should reject if an error occurs verifying certificate', () => { - const tlsAuth = new TlsAuthenticator({}) - - tlsAuth.verifyWebId = sinon.stub().yields(new Error('Error processing certificate')) - - expect(tlsAuth.extractWebId()).to.be.rejectedWith(/Error processing certificate/) - }) - - it('should resolve with a verified web id', () => { - const tlsAuth = new TlsAuthenticator({}) - - const webId = 'https://alice.example.com/#me' - tlsAuth.verifyWebId = sinon.stub().yields(null, webId) - - const certificate = { uri: webId } - - expect(tlsAuth.extractWebId(certificate)).to.become(webId) - }) - }) - - describe('loadUser()', () => { - it('should return a user instance if the webid is local', () => { - const tlsAuth = new TlsAuthenticator({ accountManager }) - - const webId = 'https://alice.example.com/#me' - - const user = tlsAuth.loadUser(webId) - - expect(user.username).to.equal('alice') - expect(user.webId).to.equal(webId) - }) - - it('should return a user instance if external user and this server is authorized provider', () => { - const tlsAuth = new TlsAuthenticator({ accountManager }) - - const externalWebId = 'https://alice.someothersite.com#me' - - tlsAuth.discoverProviderFor = sinon.stub().resolves('https://example.com') - - const user = tlsAuth.loadUser(externalWebId) - - expect(user.username).to.equal(externalWebId) - expect(user.webId).to.equal(externalWebId) - }) - }) - - describe('verifyWebId()', () => { - it('should yield an error if no cert is given', done => { - const tlsAuth = new TlsAuthenticator({}) - - tlsAuth.verifyWebId(null, (error) => { - expect(error.message).to.equal('No certificate given') - - done() - }) - }) - }) +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' +import dirtyChai from 'dirty-chai' +import chaiAsPromised from 'chai-as-promised' + +import { TlsAuthenticator } from '../../lib/models/authenticator.js' +import SolidHost from '../../lib/models/solid-host.js' +import AccountManager from '../../lib/models/account-manager.js' + +const { expect } = chai +chai.use(sinonChai) +chai.use(dirtyChai) +chai.use(chaiAsPromised) +chai.should() + +const host = SolidHost.from({ serverUri: 'https://example.com' }) +const accountManager = AccountManager.from({ host, multiuser: true }) + +describe('TlsAuthenticator', () => { + describe('fromParams()', () => { + const req = { + connection: {} + } + const options = { accountManager } + + it('should return a TlsAuthenticator instance', () => { + const tlsAuth = TlsAuthenticator.fromParams(req, options) + + expect(tlsAuth.accountManager).to.equal(accountManager) + expect(tlsAuth.connection).to.equal(req.connection) + }) + }) + + describe('findValidUser()', () => { + const webId = 'https://alice.example.com/#me' + const certificate = { uri: webId } + const connection = { + renegotiate: sinon.stub().yields(), + getPeerCertificate: sinon.stub().returns(certificate) + } + const options = { accountManager, connection } + + const tlsAuth = new TlsAuthenticator(options) + + tlsAuth.extractWebId = sinon.stub().resolves(webId) + sinon.spy(tlsAuth, 'renegotiateTls') + sinon.spy(tlsAuth, 'loadUser') + + return tlsAuth.findValidUser() + .then(validUser => { + expect(tlsAuth.renegotiateTls).to.have.been.called() + expect(connection.getPeerCertificate).to.have.been.called() + expect(tlsAuth.extractWebId).to.have.been.calledWith(certificate) + expect(tlsAuth.loadUser).to.have.been.calledWith(webId) + + expect(validUser.webId).to.equal(webId) + }) + }) + + describe('renegotiateTls()', () => { + it('should reject if an error occurs while renegotiating', () => { + const connection = { + renegotiate: sinon.stub().yields(new Error('Error renegotiating')) + } + + const tlsAuth = new TlsAuthenticator({ connection }) + + expect(tlsAuth.renegotiateTls()).to.be.rejectedWith(/Error renegotiating/) + }) + + it('should resolve if no error occurs', () => { + const connection = { + renegotiate: sinon.stub().yields(null) + } + + const tlsAuth = new TlsAuthenticator({ connection }) + + expect(tlsAuth.renegotiateTls()).to.be.fulfilled() + }) + }) + + describe('getCertificate()', () => { + it('should throw on a non-existent certificate', () => { + const connection = { + getPeerCertificate: sinon.stub().returns(null) + } + + const tlsAuth = new TlsAuthenticator({ connection }) + + expect(() => tlsAuth.getCertificate()).to.throw(/No client certificate detected/) + }) + + it('should throw on an empty certificate', () => { + const connection = { + getPeerCertificate: sinon.stub().returns({}) + } + + const tlsAuth = new TlsAuthenticator({ connection }) + + expect(() => tlsAuth.getCertificate()).to.throw(/No client certificate detected/) + }) + + it('should return a certificate if no error occurs', () => { + const certificate = { uri: 'https://alice.example.com/#me' } + const connection = { + getPeerCertificate: sinon.stub().returns(certificate) + } + + const tlsAuth = new TlsAuthenticator({ connection }) + + expect(tlsAuth.getCertificate()).to.equal(certificate) + }) + }) + + describe('extractWebId()', () => { + it('should reject if an error occurs verifying certificate', () => { + const tlsAuth = new TlsAuthenticator({}) + + tlsAuth.verifyWebId = sinon.stub().yields(new Error('Error processing certificate')) + + expect(tlsAuth.extractWebId()).to.be.rejectedWith(/Error processing certificate/) + }) + + it('should resolve with a verified web id', () => { + const tlsAuth = new TlsAuthenticator({}) + + const webId = 'https://alice.example.com/#me' + tlsAuth.verifyWebId = sinon.stub().yields(null, webId) + + const certificate = { uri: webId } + + expect(tlsAuth.extractWebId(certificate)).to.become(webId) + }) + }) + + describe('loadUser()', () => { + it('should return a user instance if the webid is local', () => { + const tlsAuth = new TlsAuthenticator({ accountManager }) + + const webId = 'https://alice.example.com/#me' + + const user = tlsAuth.loadUser(webId) + + expect(user.username).to.equal('alice') + expect(user.webId).to.equal(webId) + }) + + it('should return a user instance if external user and this server is authorized provider', () => { + const tlsAuth = new TlsAuthenticator({ accountManager }) + + const externalWebId = 'https://alice.someothersite.com#me' + + tlsAuth.discoverProviderFor = sinon.stub().resolves('https://example.com') + + const user = tlsAuth.loadUser(externalWebId) + + expect(user.username).to.equal(externalWebId) + expect(user.webId).to.equal(externalWebId) + }) + }) + + describe('verifyWebId()', () => { + it('should yield an error if no cert is given', done => { + const tlsAuth = new TlsAuthenticator({}) + + tlsAuth.verifyWebId(null, (error) => { + expect(error.message).to.equal('No certificate given') + + done() + }) + }) + }) }) diff --git a/test/unit/token-service-test.mjs b/test/unit/token-service-test.js similarity index 93% rename from test/unit/token-service-test.mjs rename to test/unit/token-service-test.js index 6be7452f4..4ac9472c5 100644 --- a/test/unit/token-service-test.mjs +++ b/test/unit/token-service-test.js @@ -1,82 +1,82 @@ -import { describe, it } from 'mocha' -import chai from 'chai' -import dirtyChai from 'dirty-chai' -import TokenService from '../../lib/services/token-service.mjs' - -const { expect } = chai -chai.use(dirtyChai) -chai.should() - -describe('TokenService', () => { - describe('constructor()', () => { - it('should init with an empty tokens store', () => { - const service = new TokenService() - - expect(service.tokens).to.exist() - }) - }) - - describe('generate()', () => { - it('should generate a new token and return a token key', () => { - const service = new TokenService() - - const token = service.generate('test') - const value = service.tokens.test[token] - - expect(token).to.exist() - expect(value).to.have.property('exp') - }) - }) - - describe('verify()', () => { - it('should return false for expired tokens', () => { - const service = new TokenService() - - const token = service.generate('foo') - - service.tokens.foo[token].exp = new Date(Date.now() - 1000) - - expect(service.verify('foo', token)).to.be.false() - }) - - it('should return the token value for valid tokens', () => { - const service = new TokenService() - - const token = service.generate('bar') - const value = service.verify('bar', token) - - expect(value).to.exist() - expect(value).to.have.property('exp') - expect(value.exp).to.be.greaterThan(new Date()) - }) - - it('should throw error for invalid token domain', () => { - const service = new TokenService() - - const token = service.generate('valid') - - expect(() => service.verify('invalid', token)).to.throw('Invalid domain for tokens: invalid') - }) - - it('should return false for non-existent tokens', () => { - const service = new TokenService() - - // First create the domain - service.generate('foo') - - expect(service.verify('foo', 'nonexistent')).to.be.false() - }) - }) - - describe('remove()', () => { - it('should remove specific tokens', () => { - const service = new TokenService() - - const token = service.generate('test') - - service.remove('test', token) - - expect(service.tokens.test).to.not.have.property(token) - }) - }) +import { describe, it } from 'mocha' +import chai from 'chai' +import dirtyChai from 'dirty-chai' +import TokenService from '../../lib/services/token-service.js' + +const { expect } = chai +chai.use(dirtyChai) +chai.should() + +describe('TokenService', () => { + describe('constructor()', () => { + it('should init with an empty tokens store', () => { + const service = new TokenService() + + expect(service.tokens).to.exist() + }) + }) + + describe('generate()', () => { + it('should generate a new token and return a token key', () => { + const service = new TokenService() + + const token = service.generate('test') + const value = service.tokens.test[token] + + expect(token).to.exist() + expect(value).to.have.property('exp') + }) + }) + + describe('verify()', () => { + it('should return false for expired tokens', () => { + const service = new TokenService() + + const token = service.generate('foo') + + service.tokens.foo[token].exp = new Date(Date.now() - 1000) + + expect(service.verify('foo', token)).to.be.false() + }) + + it('should return the token value for valid tokens', () => { + const service = new TokenService() + + const token = service.generate('bar') + const value = service.verify('bar', token) + + expect(value).to.exist() + expect(value).to.have.property('exp') + expect(value.exp).to.be.greaterThan(new Date()) + }) + + it('should throw error for invalid token domain', () => { + const service = new TokenService() + + const token = service.generate('valid') + + expect(() => service.verify('invalid', token)).to.throw('Invalid domain for tokens: invalid') + }) + + it('should return false for non-existent tokens', () => { + const service = new TokenService() + + // First create the domain + service.generate('foo') + + expect(service.verify('foo', 'nonexistent')).to.be.false() + }) + }) + + describe('remove()', () => { + it('should remove specific tokens', () => { + const service = new TokenService() + + const token = service.generate('test') + + service.remove('test', token) + + expect(service.tokens.test).to.not.have.property(token) + }) + }) }) diff --git a/test/unit/user-account-test.mjs b/test/unit/user-account-test.js similarity index 91% rename from test/unit/user-account-test.mjs rename to test/unit/user-account-test.js index 2c182a5fd..f7f025bec 100644 --- a/test/unit/user-account-test.mjs +++ b/test/unit/user-account-test.js @@ -1,37 +1,37 @@ -import { describe, it } from 'mocha' -import { expect } from 'chai' -import UserAccount from '../../lib/models/user-account.mjs' - -describe('UserAccount', () => { - describe('from()', () => { - it('initializes the object with passed in options', () => { - const options = { - username: 'alice', - webId: 'https://alice.com/#me', - name: 'Alice', - email: 'alice@alice.com' - } - - const account = UserAccount.from(options) - expect(account.username).to.equal(options.username) - expect(account.webId).to.equal(options.webId) - expect(account.name).to.equal(options.name) - expect(account.email).to.equal(options.email) - }) - }) - - describe('id getter', () => { - it('should return null if webId is null', () => { - const account = new UserAccount() - - expect(account.id).to.be.null - }) - - it('should return the WebID uri minus the protocol and slashes', () => { - const webId = 'https://alice.example.com/profile/card#me' - const account = new UserAccount({ webId }) - - expect(account.id).to.equal('alice.example.com/profile/card#me') - }) - }) -}) +import { describe, it } from 'mocha' +import { expect } from 'chai' +import UserAccount from '../../lib/models/user-account.js' + +describe('UserAccount', () => { + describe('from()', () => { + it('initializes the object with passed in options', () => { + const options = { + username: 'alice', + webId: 'https://alice.com/#me', + name: 'Alice', + email: 'alice@alice.com' + } + + const account = UserAccount.from(options) + expect(account.username).to.equal(options.username) + expect(account.webId).to.equal(options.webId) + expect(account.name).to.equal(options.name) + expect(account.email).to.equal(options.email) + }) + }) + + describe('id getter', () => { + it('should return null if webId is null', () => { + const account = new UserAccount() + + expect(account.id).to.be.null + }) + + it('should return the WebID uri minus the protocol and slashes', () => { + const webId = 'https://alice.example.com/profile/card#me' + const account = new UserAccount({ webId }) + + expect(account.id).to.equal('alice.example.com/profile/card#me') + }) + }) +}) diff --git a/test/unit/user-accounts-api-test.mjs b/test/unit/user-accounts-api-test.js similarity index 82% rename from test/unit/user-accounts-api-test.mjs rename to test/unit/user-accounts-api-test.js index 069451351..67f9c6122 100644 --- a/test/unit/user-accounts-api-test.mjs +++ b/test/unit/user-accounts-api-test.js @@ -1,59 +1,59 @@ -import chai from 'chai' -import { fileURLToPath } from 'url' -import { dirname, join } from 'path' -import HttpMocks from 'node-mocks-http' -import LDP from '../../lib/ldp.mjs' -import SolidHost from '../../lib/models/solid-host.mjs' -import AccountManager from '../../lib/models/account-manager.mjs' -import ResourceMapper from '../../lib/resource-mapper.mjs' - -import * as api from '../../lib/api/accounts/user-accounts.mjs' - -const { expect } = chai -chai.should() - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -const testAccountsDir = join(__dirname, '..', '..', 'test', 'resources', 'accounts') - -let host - -beforeEach(() => { - host = SolidHost.from({ serverUri: 'https://example.com' }) -}) - -describe('api/accounts/user-accounts', () => { - describe('newCertificate()', () => { - describe('in multi user mode', () => { - const multiuser = true - const resourceMapper = new ResourceMapper({ - rootUrl: 'https://localhost:8443/', - includeHost: multiuser, - rootPath: testAccountsDir - }) - const store = new LDP({ multiuser, resourceMapper }) - - it('should throw a 400 error if spkac param is missing', done => { - const options = { host, store, multiuser, authMethod: 'oidc' } - const accountManager = AccountManager.from(options) - - const req = { - body: { - webid: 'https://alice.example.com/#me' - }, - session: { userId: 'https://alice.example.com/#me' }, - get: () => { return 'https://example.com' } - } - const res = HttpMocks.createResponse() - - const newCertificate = api.newCertificate(accountManager) - - newCertificate(req, res, (err) => { - expect(err.status).to.equal(400) - expect(err.message).to.equal('Missing spkac parameter') - done() - }) - }) - }) - }) +import chai from 'chai' +import { fileURLToPath } from 'url' +import { dirname, join } from 'path' +import HttpMocks from 'node-mocks-http' +import LDP from '../../lib/ldp.js' +import SolidHost from '../../lib/models/solid-host.js' +import AccountManager from '../../lib/models/account-manager.js' +import ResourceMapper from '../../lib/resource-mapper.js' + +import * as api from '../../lib/api/accounts/user-accounts.js' + +const { expect } = chai +chai.should() + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +const testAccountsDir = join(__dirname, '..', '..', 'test', 'resources', 'accounts') + +let host + +beforeEach(() => { + host = SolidHost.from({ serverUri: 'https://example.com' }) +}) + +describe('api/accounts/user-accounts', () => { + describe('newCertificate()', () => { + describe('in multi user mode', () => { + const multiuser = true + const resourceMapper = new ResourceMapper({ + rootUrl: 'https://localhost:8443/', + includeHost: multiuser, + rootPath: testAccountsDir + }) + const store = new LDP({ multiuser, resourceMapper }) + + it('should throw a 400 error if spkac param is missing', done => { + const options = { host, store, multiuser, authMethod: 'oidc' } + const accountManager = AccountManager.from(options) + + const req = { + body: { + webid: 'https://alice.example.com/#me' + }, + session: { userId: 'https://alice.example.com/#me' }, + get: () => { return 'https://example.com' } + } + const res = HttpMocks.createResponse() + + const newCertificate = api.newCertificate(accountManager) + + newCertificate(req, res, (err) => { + expect(err.status).to.equal(400) + expect(err.message).to.equal('Missing spkac parameter') + done() + }) + }) + }) + }) }) diff --git a/test/unit/user-utils-test.mjs b/test/unit/user-utils-test.js similarity index 93% rename from test/unit/user-utils-test.mjs rename to test/unit/user-utils-test.js index 06cb2381e..11c95857e 100644 --- a/test/unit/user-utils-test.mjs +++ b/test/unit/user-utils-test.js @@ -1,64 +1,64 @@ -import * as userUtils from '../../lib/common/user-utils.mjs' -import $rdf from 'rdflib' -import chai from 'chai' - -const { expect } = chai - -describe('user-utils', () => { - describe('getName', () => { - let ldp - const webId = 'http://test#me' - const name = 'NAME' - - beforeEach(() => { - const store = $rdf.graph() - store.add($rdf.sym(webId), $rdf.sym('http://www.w3.org/2006/vcard/ns#fn'), $rdf.lit(name)) - ldp = { fetchGraph: () => Promise.resolve(store) } - }) - - it('should return name from graph', async () => { - const returnedName = await userUtils.getName(webId, ldp.fetchGraph) - expect(returnedName).to.equal(name) - }) - }) - - describe('getWebId', () => { - let fetchGraph - const webId = 'https://test.localhost:8443/profile/card#me' - const suffixMeta = '.meta' - - beforeEach(() => { - fetchGraph = () => Promise.resolve(`<${webId}> .`) - }) - - it('should return webId from meta file', async () => { - const returnedWebId = await userUtils.getWebId('foo', 'https://bar/', suffixMeta, fetchGraph) - expect(returnedWebId).to.equal(webId) - }) - }) - - describe('isValidUsername', () => { - it('should accect valid usernames', () => { - const usernames = [ - 'foo', - 'bar' - ] - const validUsernames = usernames.filter(username => userUtils.isValidUsername(username)) - expect(validUsernames.length).to.equal(usernames.length) - }) - - it('should not accect invalid usernames', () => { - const usernames = [ - '-', - '-a', - 'a-', - '9-', - 'alice--bob', - 'alice bob', - 'alice.bob' - ] - const validUsernames = usernames.filter(username => userUtils.isValidUsername(username)) - expect(validUsernames.length).to.equal(0) - }) - }) +import * as userUtils from '../../lib/common/user-utils.js' +import $rdf from 'rdflib' +import chai from 'chai' + +const { expect } = chai + +describe('user-utils', () => { + describe('getName', () => { + let ldp + const webId = 'http://test#me' + const name = 'NAME' + + beforeEach(() => { + const store = $rdf.graph() + store.add($rdf.sym(webId), $rdf.sym('http://www.w3.org/2006/vcard/ns#fn'), $rdf.lit(name)) + ldp = { fetchGraph: () => Promise.resolve(store) } + }) + + it('should return name from graph', async () => { + const returnedName = await userUtils.getName(webId, ldp.fetchGraph) + expect(returnedName).to.equal(name) + }) + }) + + describe('getWebId', () => { + let fetchGraph + const webId = 'https://test.localhost:8443/profile/card#me' + const suffixMeta = '.meta' + + beforeEach(() => { + fetchGraph = () => Promise.resolve(`<${webId}> .`) + }) + + it('should return webId from meta file', async () => { + const returnedWebId = await userUtils.getWebId('foo', 'https://bar/', suffixMeta, fetchGraph) + expect(returnedWebId).to.equal(webId) + }) + }) + + describe('isValidUsername', () => { + it('should accect valid usernames', () => { + const usernames = [ + 'foo', + 'bar' + ] + const validUsernames = usernames.filter(username => userUtils.isValidUsername(username)) + expect(validUsernames.length).to.equal(usernames.length) + }) + + it('should not accect invalid usernames', () => { + const usernames = [ + '-', + '-a', + 'a-', + '9-', + 'alice--bob', + 'alice bob', + 'alice.bob' + ] + const validUsernames = usernames.filter(username => userUtils.isValidUsername(username)) + expect(validUsernames.length).to.equal(0) + }) + }) }) diff --git a/test/unit/utils-test.mjs b/test/unit/utils-test.js similarity index 95% rename from test/unit/utils-test.mjs rename to test/unit/utils-test.js index 4877187ae..466dc6371 100644 --- a/test/unit/utils-test.mjs +++ b/test/unit/utils-test.js @@ -1,112 +1,112 @@ -import { describe, it } from 'mocha' -import { assert } from 'chai' - -import * as utils from '../../lib/utils.mjs' - -const { - pathBasename, - stripLineEndings, - debrack, - fullUrlForReq, - getContentType -} = utils - -describe('Utility functions', function () { - describe('pathBasename', function () { - it('should return bar as relative path for /foo/bar', function () { - assert.equal(pathBasename('/foo/bar'), 'bar') - }) - it('should return empty as relative path for /foo/', function () { - assert.equal(pathBasename('/foo/'), '') - }) - it('should return empty as relative path for /', function () { - assert.equal(pathBasename('/'), '') - }) - it('should return empty as relative path for empty path', function () { - assert.equal(pathBasename(''), '') - }) - it('should return empty as relative path for undefined path', function () { - assert.equal(pathBasename(undefined), '') - }) - }) - - describe('stripLineEndings()', () => { - it('should pass through falsy string arguments', () => { - assert.equal(stripLineEndings(''), '') - assert.equal(stripLineEndings(null), null) - assert.equal(stripLineEndings(undefined), undefined) - }) - - it('should remove line-endings characters', () => { - let str = '123\n456' - assert.equal(stripLineEndings(str), '123456') - - str = `123 -456` - assert.equal(stripLineEndings(str), '123456') - }) - }) - - describe('debrack()', () => { - it('should return null if no string is passed', () => { - assert.equal(debrack(), null) - }) - - it('should return the string if no brackets are present', () => { - assert.equal(debrack('test string'), 'test string') - }) - - it('should return the string if less than 2 chars long', () => { - assert.equal(debrack(''), '') - assert.equal(debrack('<'), '<') - }) - - it('should remove brackets if wrapping the string', () => { - assert.equal(debrack(''), 'test string') - }) - }) - - describe('fullUrlForReq()', () => { - it('should extract a fully-qualified url from an Express request', () => { - const req = { - protocol: 'https:', - get: (host) => 'example.com', - baseUrl: '/', - path: '/resource1', - query: { sort: 'desc' } - } - - assert.equal(fullUrlForReq(req), 'https://example.com/resource1?sort=desc') - }) - }) - - describe('getContentType()', () => { - describe('for Express headers', () => { - it('should not default', () => { - assert.equal(getContentType({}), '') - }) - - it('should get a basic content type', () => { - assert.equal(getContentType({ 'content-type': 'text/html' }), 'text/html') - }) - - it('should get a content type without its charset', () => { - assert.equal(getContentType({ 'content-type': 'text/html; charset=us-ascii' }), 'text/html') - }) - }) - - describe('for Fetch API headers', () => { - it('should not default', () => { - assert.equal(getContentType(new Headers({})), '') - }) - - it('should get a basic content type', () => { - assert.equal(getContentType(new Headers({ 'content-type': 'text/html' })), 'text/html') - }) - - it('should get a content type without its charset', () => { - assert.equal(getContentType(new Headers({ 'content-type': 'text/html; charset=us-ascii' })), 'text/html') - }) - }) - }) -}) +import { describe, it } from 'mocha' +import { assert } from 'chai' + +import * as utils from '../../lib/utils.js' + +const { + pathBasename, + stripLineEndings, + debrack, + fullUrlForReq, + getContentType +} = utils + +describe('Utility functions', function () { + describe('pathBasename', function () { + it('should return bar as relative path for /foo/bar', function () { + assert.equal(pathBasename('/foo/bar'), 'bar') + }) + it('should return empty as relative path for /foo/', function () { + assert.equal(pathBasename('/foo/'), '') + }) + it('should return empty as relative path for /', function () { + assert.equal(pathBasename('/'), '') + }) + it('should return empty as relative path for empty path', function () { + assert.equal(pathBasename(''), '') + }) + it('should return empty as relative path for undefined path', function () { + assert.equal(pathBasename(undefined), '') + }) + }) + + describe('stripLineEndings()', () => { + it('should pass through falsy string arguments', () => { + assert.equal(stripLineEndings(''), '') + assert.equal(stripLineEndings(null), null) + assert.equal(stripLineEndings(undefined), undefined) + }) + + it('should remove line-endings characters', () => { + let str = '123\n456' + assert.equal(stripLineEndings(str), '123456') + + str = `123 +456` + assert.equal(stripLineEndings(str), '123456') + }) + }) + + describe('debrack()', () => { + it('should return null if no string is passed', () => { + assert.equal(debrack(), null) + }) + + it('should return the string if no brackets are present', () => { + assert.equal(debrack('test string'), 'test string') + }) + + it('should return the string if less than 2 chars long', () => { + assert.equal(debrack(''), '') + assert.equal(debrack('<'), '<') + }) + + it('should remove brackets if wrapping the string', () => { + assert.equal(debrack(''), 'test string') + }) + }) + + describe('fullUrlForReq()', () => { + it('should extract a fully-qualified url from an Express request', () => { + const req = { + protocol: 'https:', + get: (host) => 'example.com', + baseUrl: '/', + path: '/resource1', + query: { sort: 'desc' } + } + + assert.equal(fullUrlForReq(req), 'https://example.com/resource1?sort=desc') + }) + }) + + describe('getContentType()', () => { + describe('for Express headers', () => { + it('should not default', () => { + assert.equal(getContentType({}), '') + }) + + it('should get a basic content type', () => { + assert.equal(getContentType({ 'content-type': 'text/html' }), 'text/html') + }) + + it('should get a content type without its charset', () => { + assert.equal(getContentType({ 'content-type': 'text/html; charset=us-ascii' }), 'text/html') + }) + }) + + describe('for Fetch API headers', () => { + it('should not default', () => { + assert.equal(getContentType(new Headers({})), '') + }) + + it('should get a basic content type', () => { + assert.equal(getContentType(new Headers({ 'content-type': 'text/html' })), 'text/html') + }) + + it('should get a content type without its charset', () => { + assert.equal(getContentType(new Headers({ 'content-type': 'text/html; charset=us-ascii' })), 'text/html') + }) + }) + }) +}) diff --git a/test/utils.mjs b/test/utils.js similarity index 95% rename from test/utils.mjs rename to test/utils.js index e09e78a48..4350955a8 100644 --- a/test/utils.mjs +++ b/test/utils.js @@ -1,204 +1,204 @@ -// import fs from 'fs-extra' // see fs-extra/esm and fs-extra doc - -import fs from 'fs' -import path from 'path' -import dns from 'dns' -import https from 'https' -import { createRequire } from 'module' -import rimraf from 'rimraf' -import fse from 'fs-extra' -import Provider from '@solid/oidc-op' -import supertest from 'supertest' -import ldnode from '../index.mjs' -import { fileURLToPath } from 'url' - -const require = createRequire(import.meta.url) -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const OIDCProvider = Provider - -const TEST_HOSTS = ['nic.localhost', 'tim.localhost', 'nicola.localhost'] - -// Configurable test root directory -// For custom route -let TEST_ROOT = path.join(__dirname, '/resources/') -// For default root (process.cwd()): -// let TEST_ROOT = path.join(process.cwd(), 'test-esm/resources') - -export function setTestRoot (rootPath) { - TEST_ROOT = rootPath -} -export function getTestRoot () { - return TEST_ROOT -} - -export function rm (file) { - return rimraf.sync(path.join(TEST_ROOT, file)) -} - -export function cleanDir (dirPath) { - fse.removeSync(path.join(dirPath, '.well-known/.acl')) - fse.removeSync(path.join(dirPath, '.acl')) - fse.removeSync(path.join(dirPath, 'favicon.ico')) - fse.removeSync(path.join(dirPath, 'favicon.ico.acl')) - fse.removeSync(path.join(dirPath, 'index.html')) - fse.removeSync(path.join(dirPath, 'index.html.acl')) - fse.removeSync(path.join(dirPath, 'robots.txt')) - fse.removeSync(path.join(dirPath, 'robots.txt.acl')) -} - -export function write (text, file) { - // console.log('Writing to', path.join(TEST_ROOT, file)) - // fs.mkdirSync(path.dirname(path.join(TEST_ROOT, file), { recursive: true })) - return fs.writeFileSync(path.join(TEST_ROOT, file), text) -} - -export function cp (src, dest) { - return fse.copySync( - path.join(TEST_ROOT, src), - path.join(TEST_ROOT, dest)) -} - -export function read (file) { - // console.log('Reading from', path.join(TEST_ROOT, file)) - return fs.readFileSync(path.join(TEST_ROOT, file), { - encoding: 'utf8' - }) -} - -// Backs up the given file -export function backup (src) { - cp(src, src + '.bak') -} - -// Restores a backup of the given file -export function restore (src) { - cp(src + '.bak', src) - rm(src + '.bak') -} - -// Verifies that all HOSTS entries are present -export function checkDnsSettings () { - return Promise.all(TEST_HOSTS.map(hostname => { - return new Promise((resolve, reject) => { - dns.lookup(hostname, (error, ip) => { - if (error || (ip !== '127.0.0.1' && ip !== '::1')) { - reject(error) - } else { - resolve(true) - } - }) - }) - })) - .catch(() => { - throw new Error(`Expected HOSTS entries of 127.0.0.1 for ${TEST_HOSTS.join()}`) - }) -} - -/** - * @param configPath {string} - * - * @returns {Promise} - */ -export function loadProvider (configPath) { - return Promise.resolve() - .then(() => { - const config = require(configPath) - - const provider = new OIDCProvider(config) - - return provider.initializeKeyChain(config.keys) - }) -} - -export function createServer (options) { - // console.log('Creating server with root:', options.root || process.cwd()) - return ldnode.createServer(options) -} - -export function setupSupertestServer (options) { - const ldpServer = createServer(options) - return supertest(ldpServer) -} - -// Lightweight adapter to replace `request` with `node-fetch` in tests -// Supports signatures: -// - request(options, cb) -// - request(url, options, cb) -// And methods: get, post, put, patch, head, delete, del -function buildAgentFn (options = {}) { - const aOpts = options.agentOptions || {} - if (!aOpts || (!aOpts.cert && !aOpts.key)) { - return undefined - } - const httpsAgent = new https.Agent({ - cert: aOpts.cert, - key: aOpts.key, - // Tests often run with NODE_TLS_REJECT_UNAUTHORIZED=0; mirror that here - rejectUnauthorized: false - }) - return (parsedURL) => parsedURL.protocol === 'https:' ? httpsAgent : undefined -} - -async function doFetch (method, url, options = {}, cb) { - try { - const headers = options.headers || {} - const body = options.body - const agent = buildAgentFn(options) - const res = await fetch(url, { method, headers, body, agent }) - // Build a response object similar to `request`'s - const headersObj = {} - res.headers.forEach((value, key) => { headersObj[key] = value }) - const response = { - statusCode: res.status, - statusMessage: res.statusText, - headers: headersObj - } - const hasBody = method !== 'HEAD' - const text = hasBody ? await res.text() : '' - cb(null, response, text) - } catch (err) { - cb(err) - } -} - -function requestAdapter (arg1, arg2, arg3) { - let url, options, cb - if (typeof arg1 === 'string') { - url = arg1 - options = arg2 || {} - cb = arg3 - } else { - options = arg1 || {} - url = options.url - cb = arg2 - } - const method = (options && options.method) || 'GET' - return doFetch(method, url, options, cb) -} - -;['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE'].forEach(m => { - const name = m.toLowerCase() - requestAdapter[name] = (options, cb) => doFetch(m, options.url, options, cb) -}) -// Alias -requestAdapter.del = requestAdapter.delete - -export const httpRequest = requestAdapter - -// Provide default export for compatibility -export default { - rm, - cleanDir, - write, - cp, - read, - backup, - restore, - checkDnsSettings, - loadProvider, - createServer, - setupSupertestServer, - httpRequest -} +// import fs from 'fs-extra' // see fs-extra/esm and fs-extra doc + +import fs from 'fs' +import path from 'path' +import dns from 'dns' +import https from 'https' +import { createRequire } from 'module' +import rimraf from 'rimraf' +import fse from 'fs-extra' +import Provider from '@solid/oidc-op' +import supertest from 'supertest' +import ldnode from '../index.js' +import { fileURLToPath } from 'url' + +const require = createRequire(import.meta.url) +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const OIDCProvider = Provider + +const TEST_HOSTS = ['nic.localhost', 'tim.localhost', 'nicola.localhost'] + +// Configurable test root directory +// For custom route +let TEST_ROOT = path.join(__dirname, '/resources/') +// For default root (process.cwd()): +// let TEST_ROOT = path.join(process.cwd(), 'test-esm/resources') + +export function setTestRoot (rootPath) { + TEST_ROOT = rootPath +} +export function getTestRoot () { + return TEST_ROOT +} + +export function rm (file) { + return rimraf.sync(path.join(TEST_ROOT, file)) +} + +export function cleanDir (dirPath) { + fse.removeSync(path.join(dirPath, '.well-known/.acl')) + fse.removeSync(path.join(dirPath, '.acl')) + fse.removeSync(path.join(dirPath, 'favicon.ico')) + fse.removeSync(path.join(dirPath, 'favicon.ico.acl')) + fse.removeSync(path.join(dirPath, 'index.html')) + fse.removeSync(path.join(dirPath, 'index.html.acl')) + fse.removeSync(path.join(dirPath, 'robots.txt')) + fse.removeSync(path.join(dirPath, 'robots.txt.acl')) +} + +export function write (text, file) { + // console.log('Writing to', path.join(TEST_ROOT, file)) + // fs.mkdirSync(path.dirname(path.join(TEST_ROOT, file), { recursive: true })) + return fs.writeFileSync(path.join(TEST_ROOT, file), text) +} + +export function cp (src, dest) { + return fse.copySync( + path.join(TEST_ROOT, src), + path.join(TEST_ROOT, dest)) +} + +export function read (file) { + // console.log('Reading from', path.join(TEST_ROOT, file)) + return fs.readFileSync(path.join(TEST_ROOT, file), { + encoding: 'utf8' + }) +} + +// Backs up the given file +export function backup (src) { + cp(src, src + '.bak') +} + +// Restores a backup of the given file +export function restore (src) { + cp(src + '.bak', src) + rm(src + '.bak') +} + +// Verifies that all HOSTS entries are present +export function checkDnsSettings () { + return Promise.all(TEST_HOSTS.map(hostname => { + return new Promise((resolve, reject) => { + dns.lookup(hostname, (error, ip) => { + if (error || (ip !== '127.0.0.1' && ip !== '::1')) { + reject(error) + } else { + resolve(true) + } + }) + }) + })) + .catch(() => { + throw new Error(`Expected HOSTS entries of 127.0.0.1 for ${TEST_HOSTS.join()}`) + }) +} + +/** + * @param configPath {string} + * + * @returns {Promise} + */ +export function loadProvider (configPath) { + return Promise.resolve() + .then(() => { + const config = require(configPath) + + const provider = new OIDCProvider(config) + + return provider.initializeKeyChain(config.keys) + }) +} + +export function createServer (options) { + // console.log('Creating server with root:', options.root || process.cwd()) + return ldnode.createServer(options) +} + +export function setupSupertestServer (options) { + const ldpServer = createServer(options) + return supertest(ldpServer) +} + +// Lightweight adapter to replace `request` with `node-fetch` in tests +// Supports signatures: +// - request(options, cb) +// - request(url, options, cb) +// And methods: get, post, put, patch, head, delete, del +function buildAgentFn (options = {}) { + const aOpts = options.agentOptions || {} + if (!aOpts || (!aOpts.cert && !aOpts.key)) { + return undefined + } + const httpsAgent = new https.Agent({ + cert: aOpts.cert, + key: aOpts.key, + // Tests often run with NODE_TLS_REJECT_UNAUTHORIZED=0; mirror that here + rejectUnauthorized: false + }) + return (parsedURL) => parsedURL.protocol === 'https:' ? httpsAgent : undefined +} + +async function doFetch (method, url, options = {}, cb) { + try { + const headers = options.headers || {} + const body = options.body + const agent = buildAgentFn(options) + const res = await fetch(url, { method, headers, body, agent }) + // Build a response object similar to `request`'s + const headersObj = {} + res.headers.forEach((value, key) => { headersObj[key] = value }) + const response = { + statusCode: res.status, + statusMessage: res.statusText, + headers: headersObj + } + const hasBody = method !== 'HEAD' + const text = hasBody ? await res.text() : '' + cb(null, response, text) + } catch (err) { + cb(err) + } +} + +function requestAdapter (arg1, arg2, arg3) { + let url, options, cb + if (typeof arg1 === 'string') { + url = arg1 + options = arg2 || {} + cb = arg3 + } else { + options = arg1 || {} + url = options.url + cb = arg2 + } + const method = (options && options.method) || 'GET' + return doFetch(method, url, options, cb) +} + +;['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE'].forEach(m => { + const name = m.toLowerCase() + requestAdapter[name] = (options, cb) => doFetch(m, options.url, options, cb) +}) +// Alias +requestAdapter.del = requestAdapter.delete + +export const httpRequest = requestAdapter + +// Provide default export for compatibility +export default { + rm, + cleanDir, + write, + cp, + read, + backup, + restore, + checkDnsSettings, + loadProvider, + createServer, + setupSupertestServer, + httpRequest +} diff --git a/test/utils/index.mjs b/test/utils/index.js similarity index 95% rename from test/utils/index.mjs rename to test/utils/index.js index b64156439..f027ca4ff 100644 --- a/test/utils/index.mjs +++ b/test/utils/index.js @@ -1,166 +1,166 @@ -import fs from 'fs-extra' -import rimraf from 'rimraf' -import path from 'path' -import { fileURLToPath } from 'url' -import OIDCProvider from '@solid/oidc-op' -import dns from 'dns' -import ldnode from '../../index.mjs' -import supertest from 'supertest' -import https from 'https' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const TEST_HOSTS = ['nic.localhost', 'tim.localhost', 'nicola.localhost'] - -export function rm (file) { - return rimraf.sync(path.normalize(path.join(__dirname, '../resources/' + file))) -} - -export function cleanDir (dirPath) { - fs.removeSync(path.normalize(path.join(dirPath, '.well-known/.acl'))) - fs.removeSync(path.normalize(path.join(dirPath, '.acl'))) - fs.removeSync(path.normalize(path.join(dirPath, 'favicon.ico'))) - fs.removeSync(path.normalize(path.join(dirPath, 'favicon.ico.acl'))) - fs.removeSync(path.normalize(path.join(dirPath, 'index.html'))) - fs.removeSync(path.normalize(path.join(dirPath, 'index.html.acl'))) - fs.removeSync(path.normalize(path.join(dirPath, 'robots.txt'))) - fs.removeSync(path.normalize(path.join(dirPath, 'robots.txt.acl'))) -} - -export function write (text, file) { - return fs.writeFileSync(path.normalize(path.join(__dirname, '../resources/' + file)), text) -} - -export function cp (src, dest) { - return fs.copySync( - path.normalize(path.join(__dirname, '../resources/' + src)), - path.normalize(path.join(__dirname, '../resources/' + dest))) -} - -export function read (file) { - return fs.readFileSync(path.normalize(path.join(__dirname, '../resources/' + file)), { - encoding: 'utf8' - }) -} - -// Backs up the given file -export function backup (src) { - cp(src, src + '.bak') -} - -// Restores a backup of the given file -export function restore (src) { - cp(src + '.bak', src) - rm(src + '.bak') -} - -// Verifies that all HOSTS entries are present -export function checkDnsSettings () { - return Promise.all(TEST_HOSTS.map(hostname => { - return new Promise((resolve, reject) => { - dns.lookup(hostname, (error, ip) => { - if (error || (ip !== '127.0.0.1' && ip !== '::1')) { - reject(error) - } else { - resolve(true) - } - }) - }) - })) - .catch(() => { - throw new Error(`Expected HOSTS entries of 127.0.0.1 for ${TEST_HOSTS.join()}`) - }) -} - -/** - * @param configPath {string} - * - * @returns {Promise} - */ -export function loadProvider (configPath) { - return Promise.resolve() - .then(async () => { - const { default: config } = await import(configPath) - - const provider = new OIDCProvider(config) - - return provider.initializeKeyChain(config.keys) - }) -} - -export { createServer } -function createServer (options) { - return ldnode.createServer(options) -} - -export { setupSupertestServer } -function setupSupertestServer (options) { - const ldpServer = createServer(options) - return supertest(ldpServer) -} - -// Lightweight adapter to replace `request` with `node-fetch` in tests -// Supports signatures: -// - request(options, cb) -// - request(url, options, cb) -// And methods: get, post, put, patch, head, delete, del -function buildAgentFn (options = {}) { - const aOpts = options.agentOptions || {} - if (!aOpts || (!aOpts.cert && !aOpts.key)) { - return undefined - } - const httpsAgent = new https.Agent({ - cert: aOpts.cert, - key: aOpts.key, - // Tests often run with NODE_TLS_REJECT_UNAUTHORIZED=0; mirror that here - rejectUnauthorized: false - }) - return (parsedURL) => parsedURL.protocol === 'https:' ? httpsAgent : undefined -} - -async function doFetch (method, url, options = {}, cb) { - try { - const headers = options.headers || {} - const body = options.body - const agent = buildAgentFn(options) - const res = await fetch(url, { method, headers, body, agent }) - // Build a response object similar to `request`'s - const headersObj = {} - res.headers.forEach((value, key) => { headersObj[key] = value }) - const response = { - statusCode: res.status, - statusMessage: res.statusText, - headers: headersObj - } - const hasBody = method !== 'HEAD' - const text = hasBody ? await res.text() : '' - cb(null, response, text) - } catch (err) { - cb(err) - } -} - -function requestAdapter (arg1, arg2, arg3) { - let url, options, cb - if (typeof arg1 === 'string') { - url = arg1 - options = arg2 || {} - cb = arg3 - } else { - options = arg1 || {} - url = options.url - cb = arg2 - } - const method = (options && options.method) || 'GET' - return doFetch(method, url, options, cb) -} - -;['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE'].forEach(m => { - const name = m.toLowerCase() - requestAdapter[name] = (options, cb) => doFetch(m, options.url, options, cb) -}) -// Alias -requestAdapter.del = requestAdapter.delete - -export const httpRequest = requestAdapter +import fs from 'fs-extra' +import rimraf from 'rimraf' +import path from 'path' +import { fileURLToPath } from 'url' +import OIDCProvider from '@solid/oidc-op' +import dns from 'dns' +import ldnode from '../../index.js' +import supertest from 'supertest' +import https from 'https' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const TEST_HOSTS = ['nic.localhost', 'tim.localhost', 'nicola.localhost'] + +export function rm (file) { + return rimraf.sync(path.normalize(path.join(__dirname, '../resources/' + file))) +} + +export function cleanDir (dirPath) { + fs.removeSync(path.normalize(path.join(dirPath, '.well-known/.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, '.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, 'favicon.ico'))) + fs.removeSync(path.normalize(path.join(dirPath, 'favicon.ico.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, 'index.html'))) + fs.removeSync(path.normalize(path.join(dirPath, 'index.html.acl'))) + fs.removeSync(path.normalize(path.join(dirPath, 'robots.txt'))) + fs.removeSync(path.normalize(path.join(dirPath, 'robots.txt.acl'))) +} + +export function write (text, file) { + return fs.writeFileSync(path.normalize(path.join(__dirname, '../resources/' + file)), text) +} + +export function cp (src, dest) { + return fs.copySync( + path.normalize(path.join(__dirname, '../resources/' + src)), + path.normalize(path.join(__dirname, '../resources/' + dest))) +} + +export function read (file) { + return fs.readFileSync(path.normalize(path.join(__dirname, '../resources/' + file)), { + encoding: 'utf8' + }) +} + +// Backs up the given file +export function backup (src) { + cp(src, src + '.bak') +} + +// Restores a backup of the given file +export function restore (src) { + cp(src + '.bak', src) + rm(src + '.bak') +} + +// Verifies that all HOSTS entries are present +export function checkDnsSettings () { + return Promise.all(TEST_HOSTS.map(hostname => { + return new Promise((resolve, reject) => { + dns.lookup(hostname, (error, ip) => { + if (error || (ip !== '127.0.0.1' && ip !== '::1')) { + reject(error) + } else { + resolve(true) + } + }) + }) + })) + .catch(() => { + throw new Error(`Expected HOSTS entries of 127.0.0.1 for ${TEST_HOSTS.join()}`) + }) +} + +/** + * @param configPath {string} + * + * @returns {Promise} + */ +export function loadProvider (configPath) { + return Promise.resolve() + .then(async () => { + const { default: config } = await import(configPath) + + const provider = new OIDCProvider(config) + + return provider.initializeKeyChain(config.keys) + }) +} + +export { createServer } +function createServer (options) { + return ldnode.createServer(options) +} + +export { setupSupertestServer } +function setupSupertestServer (options) { + const ldpServer = createServer(options) + return supertest(ldpServer) +} + +// Lightweight adapter to replace `request` with `node-fetch` in tests +// Supports signatures: +// - request(options, cb) +// - request(url, options, cb) +// And methods: get, post, put, patch, head, delete, del +function buildAgentFn (options = {}) { + const aOpts = options.agentOptions || {} + if (!aOpts || (!aOpts.cert && !aOpts.key)) { + return undefined + } + const httpsAgent = new https.Agent({ + cert: aOpts.cert, + key: aOpts.key, + // Tests often run with NODE_TLS_REJECT_UNAUTHORIZED=0; mirror that here + rejectUnauthorized: false + }) + return (parsedURL) => parsedURL.protocol === 'https:' ? httpsAgent : undefined +} + +async function doFetch (method, url, options = {}, cb) { + try { + const headers = options.headers || {} + const body = options.body + const agent = buildAgentFn(options) + const res = await fetch(url, { method, headers, body, agent }) + // Build a response object similar to `request`'s + const headersObj = {} + res.headers.forEach((value, key) => { headersObj[key] = value }) + const response = { + statusCode: res.status, + statusMessage: res.statusText, + headers: headersObj + } + const hasBody = method !== 'HEAD' + const text = hasBody ? await res.text() : '' + cb(null, response, text) + } catch (err) { + cb(err) + } +} + +function requestAdapter (arg1, arg2, arg3) { + let url, options, cb + if (typeof arg1 === 'string') { + url = arg1 + options = arg2 || {} + cb = arg3 + } else { + options = arg1 || {} + url = options.url + cb = arg2 + } + const method = (options && options.method) || 'GET' + return doFetch(method, url, options, cb) +} + +;['GET', 'POST', 'PUT', 'PATCH', 'HEAD', 'DELETE'].forEach(m => { + const name = m.toLowerCase() + requestAdapter[name] = (options, cb) => doFetch(m, options.url, options, cb) +}) +// Alias +requestAdapter.del = requestAdapter.delete + +export const httpRequest = requestAdapter diff --git a/test/validate-turtle.mjs b/test/validate-turtle.js similarity index 100% rename from test/validate-turtle.mjs rename to test/validate-turtle.js