2017-10-13 16:17:18 -04:00
|
|
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
|
|
2018-07-25 21:34:16 -04:00
|
|
|
|
const chai = require('chai');
|
|
|
|
|
chai.use(require('chai-http'));
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const should = chai.should();
|
2018-07-25 21:34:16 -04:00
|
|
|
|
|
2018-07-26 19:01:16 -04:00
|
|
|
|
const ModelPath = '../app/models/';
|
|
|
|
|
const User = require(ModelPath + 'User.js');
|
|
|
|
|
const Upload = require(ModelPath + 'Upload.js');
|
|
|
|
|
const Key = require(ModelPath + 'Key.js');
|
|
|
|
|
const Invite = require(ModelPath + 'Invite.js');
|
2017-10-13 16:17:18 -04:00
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
const util = require('./testUtil.js');
|
|
|
|
|
const canonicalize = require('../app/util/canonicalize').canonicalize;
|
2017-10-13 16:17:18 -04:00
|
|
|
|
|
2018-07-26 17:34:47 -04:00
|
|
|
|
const config = require('config');
|
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
let app;
|
|
|
|
|
let server;
|
|
|
|
|
let agent;
|
|
|
|
|
|
|
|
|
|
before(() => {
|
|
|
|
|
const main = require('../server.js');
|
|
|
|
|
app = main.app;
|
|
|
|
|
server = main.server;
|
|
|
|
|
agent = chai.request.agent(app);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
after(() => {
|
|
|
|
|
server.close();
|
|
|
|
|
});
|
|
|
|
|
|
2018-07-27 14:23:23 -04:00
|
|
|
|
describe('Authentication', function() {
|
2018-07-25 18:45:38 -04:00
|
|
|
|
beforeEach(async () => util.clearDatabase());
|
2017-10-13 16:17:18 -04:00
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
describe('/POST register', () => {
|
|
|
|
|
describe('0 Valid Request', () => {
|
|
|
|
|
async function verifySuccessfulRegister(user) {
|
|
|
|
|
await util.createTestInvite();
|
2018-07-24 19:39:55 -04:00
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
const res = await util.registerUser(user, agent);
|
2018-07-24 19:39:55 -04:00
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
res.should.have.status(200);
|
|
|
|
|
res.body.should.be.a('object');
|
|
|
|
|
res.body.should.have.property('message').eql('Registration successful.');
|
2018-07-24 19:39:55 -04:00
|
|
|
|
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const userCount = await User.countDocuments({displayname: user.displayname});
|
2018-07-26 16:54:08 -04:00
|
|
|
|
userCount.should.equal(1);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const inviteCount = await Invite.countDocuments({code: user.invite, recipient: canonicalize(user.displayname)});
|
2018-07-26 16:54:08 -04:00
|
|
|
|
inviteCount.should.equal(1);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('MUST register a valid user with a valid invite', async () =>
|
2018-07-26 19:40:42 -04:00
|
|
|
|
verifySuccessfulRegister({displayname: 'user', password: 'pass', invite: 'code'})
|
2018-07-25 18:45:38 -04:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
it('MUST register a username with unicode symbols and a valid invite', async () =>
|
2018-07-26 19:40:42 -04:00
|
|
|
|
verifySuccessfulRegister({displayname: 'ᴮᴵᴳᴮᴵᴿᴰ', password: 'pass', invite: 'code'})
|
2018-07-25 18:45:38 -04:00
|
|
|
|
);
|
2017-10-13 16:17:18 -04:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
describe('1 Invalid Invites', () => {
|
|
|
|
|
async function verifyRejectedInvite(invite, message) {
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const user = {displayname: 'user', password: 'pass', invite: 'code'};
|
2018-07-25 18:45:38 -04:00
|
|
|
|
if (invite) {
|
2018-07-26 13:17:37 -04:00
|
|
|
|
await util.createInvite(invite, agent);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
user.invite = invite.code;
|
|
|
|
|
}
|
2017-10-13 16:17:18 -04:00
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
const res = await(util.registerUser(user, agent));
|
2018-07-25 18:45:38 -04:00
|
|
|
|
res.should.have.status(422);
|
|
|
|
|
res.body.should.be.a('object');
|
|
|
|
|
res.body.should.have.property('message').eql(message);
|
2018-07-26 16:54:08 -04:00
|
|
|
|
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const inviteCount = await Invite.countDocuments({code: user.invite, recipient: canonicalize(user.displayname)});
|
2018-07-26 16:54:08 -04:00
|
|
|
|
inviteCount.should.equal(0);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
}
|
2017-10-13 16:17:18 -04:00
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
it('MUST NOT register a nonexistant invite', async () =>
|
|
|
|
|
verifyRejectedInvite(null, 'Invalid invite code.')
|
|
|
|
|
);
|
2018-07-24 19:39:55 -04:00
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
it('MUST NOT register a used invite', async () =>
|
2018-07-26 19:40:42 -04:00
|
|
|
|
verifyRejectedInvite({code: 'code', used: new Date(), issuer: 'Mocha'}, 'Invite already used.')
|
2018-07-25 18:45:38 -04:00
|
|
|
|
);
|
2018-07-24 19:39:55 -04:00
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
it('MUST NOT register an expired invite', async () =>
|
2018-07-27 14:23:23 -04:00
|
|
|
|
verifyRejectedInvite({code: 'code', expires: new Date(), issuer: 'Mocha'}, 'Invite expired.')
|
2018-07-25 18:45:38 -04:00
|
|
|
|
);
|
2017-10-13 16:17:18 -04:00
|
|
|
|
});
|
|
|
|
|
|
2018-07-24 19:39:55 -04:00
|
|
|
|
|
2018-07-26 19:40:42 -04:00
|
|
|
|
describe('2 Invalid Displaynames', () => {
|
2018-07-25 18:45:38 -04:00
|
|
|
|
async function verifyRejectedUsername(user, message) {
|
2018-07-26 13:17:37 -04:00
|
|
|
|
const res = await util.registerUser(user, agent);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
res.should.have.status(422);
|
|
|
|
|
res.body.should.be.a('object');
|
2018-07-26 16:54:08 -04:00
|
|
|
|
res.body.should.have.property('message').equal(message);
|
|
|
|
|
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const inviteCount = await Invite.countDocuments({code: user.invite, recipient: canonicalize(user.displayname)});
|
2018-07-26 16:54:08 -04:00
|
|
|
|
inviteCount.should.equal(0);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
}
|
2018-07-24 19:39:55 -04:00
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
it('MUST NOT register a duplicate username', async () => {
|
|
|
|
|
await util.createTestInvites(2);
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const user0 = {displayname: 'user', password: 'pass', invite: 'code0'};
|
|
|
|
|
const user1 = {displayname: 'user', password: 'diff', invite: 'code1'};
|
2018-07-25 18:45:38 -04:00
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
await util.registerUser(user0, agent);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
return verifyRejectedUsername(user1, 'Username in use.');
|
2018-07-24 19:39:55 -04:00
|
|
|
|
});
|
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
it('MUST NOT register a username with a duplicate canonical name', async () => {
|
|
|
|
|
await util.createTestInvites(2);
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const user0 = {displayname: 'bigbird', password: 'pass', invite: 'code0'};
|
|
|
|
|
const user1 = {displayname: 'ᴮᴵᴳᴮᴵᴿᴰ', password: 'diff', invite: 'code1'};
|
2018-07-25 18:45:38 -04:00
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
await util.registerUser(user0, agent);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
return verifyRejectedUsername(user1, 'Username in use.');
|
2018-07-24 19:39:55 -04:00
|
|
|
|
});
|
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
it('MUST NOT register a username containing whitespace', async () => {
|
|
|
|
|
await util.createTestInvites(3);
|
2018-07-24 19:39:55 -04:00
|
|
|
|
const users = [
|
2018-07-26 19:40:42 -04:00
|
|
|
|
{displayname: 'user name', password: 'pass', invite: 'code0'},
|
|
|
|
|
{displayname: 'user name', password: 'pass', invite: 'code1'},
|
|
|
|
|
{displayname: 'user name', password: 'pass', invite: 'code2'}
|
2018-07-24 19:39:55 -04:00
|
|
|
|
];
|
2018-07-25 18:45:38 -04:00
|
|
|
|
|
2018-07-24 19:39:55 -04:00
|
|
|
|
const failMsg = 'Username contains invalid characters.';
|
2018-07-25 18:45:38 -04:00
|
|
|
|
return Promise.all(users.map(user => verifyRejectedUsername(user, failMsg)));
|
2018-07-24 19:39:55 -04:00
|
|
|
|
});
|
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
it('MUST NOT register a username containing HTML', async () => {
|
|
|
|
|
await util.createTestInvite();
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const user = {displayname: 'user<svg/onload=alert("XSS")>', password: 'pass', invite: 'code'};
|
2018-07-25 18:45:38 -04:00
|
|
|
|
return verifyRejectedUsername(user, 'Username contains invalid characters.');
|
2018-07-24 19:39:55 -04:00
|
|
|
|
});
|
2018-07-25 01:44:45 -04:00
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
it('MUST NOT register a username with too many characters', async () => {
|
|
|
|
|
await util.createTestInvite();
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const user = {displayname: '123456789_123456789_123456789_1234567', password: 'pass', invite: 'code'};
|
2018-07-25 18:45:38 -04:00
|
|
|
|
return verifyRejectedUsername(user, 'Username too long.');
|
2018-07-25 01:44:45 -04:00
|
|
|
|
})
|
2018-07-24 19:39:55 -04:00
|
|
|
|
});
|
2017-10-13 16:17:18 -04:00
|
|
|
|
});
|
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
describe('/POST login', () => {
|
|
|
|
|
async function verifySuccessfulLogin(credentials) {
|
2018-07-25 21:34:16 -04:00
|
|
|
|
const res = await util.login(credentials, agent);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
res.should.have.status(200);
|
2018-07-25 21:34:16 -04:00
|
|
|
|
res.body.should.have.property('message').equal('Logged in.');
|
|
|
|
|
res.should.have.cookie('session.id');
|
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
const whoami = await util.whoami(agent);
|
|
|
|
|
whoami.should.have.status(200);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function verifyFailedLogin(credentials) {
|
2018-07-26 13:17:37 -04:00
|
|
|
|
const res = await util.login(credentials, agent);
|
2018-07-25 18:45:38 -04:00
|
|
|
|
res.should.have.status(401);
|
|
|
|
|
res.body.should.be.a('object');
|
2018-07-25 21:34:16 -04:00
|
|
|
|
res.body.should.have.property('message').equal('Unauthorized.');
|
2018-07-25 18:45:38 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('0 Valid Request', () => {
|
|
|
|
|
it('SHOULD accept a valid user with a valid password', async () => {
|
2018-07-26 13:17:37 -04:00
|
|
|
|
await util.createTestUser(agent);
|
2018-07-26 19:40:42 -04:00
|
|
|
|
return verifySuccessfulLogin({displayname: 'user', password: 'pass'});
|
2018-07-25 18:45:38 -04:00
|
|
|
|
});
|
2018-07-26 13:17:37 -04:00
|
|
|
|
|
|
|
|
|
it('SHOULD accept any non-normalized variant of a username with a valid password', async () => {
|
2018-07-26 19:01:16 -04:00
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
})
|
2017-10-13 16:17:18 -04:00
|
|
|
|
});
|
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
|
|
|
|
|
describe('1 Invalid Password', () => {
|
|
|
|
|
it('SHOULD NOT accept an invalid password', async () => {
|
2018-07-26 13:17:37 -04:00
|
|
|
|
await util.createTestUser(agent);
|
2018-07-26 19:40:42 -04:00
|
|
|
|
return verifyFailedLogin({displayname: 'user', password: 'bogus'});
|
2018-07-25 18:45:38 -04:00
|
|
|
|
});
|
2017-10-13 16:17:18 -04:00
|
|
|
|
});
|
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
describe('2 Invalid User', () => {
|
|
|
|
|
it('SHOULD NOT accept an invalid user', async () =>
|
2018-07-26 19:40:42 -04:00
|
|
|
|
verifyFailedLogin({displayname: 'bogus', password: 'bogus'})
|
2018-07-25 18:45:38 -04:00
|
|
|
|
);
|
2017-10-13 16:17:18 -04:00
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2018-07-27 14:23:23 -04:00
|
|
|
|
describe('Uploading', () => {
|
2018-07-26 13:17:37 -04:00
|
|
|
|
beforeEach(async () => util.clearDatabase());
|
|
|
|
|
|
2018-07-25 21:34:16 -04:00
|
|
|
|
describe('/POST upload', () => {
|
2018-07-26 19:40:42 -04:00
|
|
|
|
async function verifySuccessfulUpload(file, username) {
|
2018-07-26 16:54:08 -04:00
|
|
|
|
// Get file stats beforehand
|
|
|
|
|
const [fileHash, fileSize] = await Promise.all([util.fileHash(file), util.fileSize(file)]);
|
|
|
|
|
|
|
|
|
|
// Get the user stats beforehand
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const userBefore = await User.findOne({username: username}, {_id: 0, uploadCount: 1, uploadSize: 1});
|
2018-07-26 16:54:08 -04:00
|
|
|
|
|
|
|
|
|
// Submit the upload and verify the result
|
2018-07-26 13:17:37 -04:00
|
|
|
|
const res = await util.upload(file, agent);
|
|
|
|
|
res.should.have.status(200);
|
|
|
|
|
res.body.should.be.a('object');
|
|
|
|
|
res.body.should.have.property('url');
|
|
|
|
|
res.body.should.have.property('id').match(/^[a-z]{6}$/);
|
2018-07-26 16:54:08 -04:00
|
|
|
|
|
|
|
|
|
// Find the uploaded file in the database
|
|
|
|
|
const upload = await Upload.findOne({id: res.body.id}, {_id: 0, id: 1, file: 1});
|
|
|
|
|
const uploadFile = upload.file.path;
|
|
|
|
|
upload.should.be.a('object');
|
|
|
|
|
upload.id.should.equal(res.body.id);
|
|
|
|
|
|
|
|
|
|
// Verify the uploaded file is the same as the file now on disk
|
|
|
|
|
const [uploadHash, uploadSize] = await Promise.all([util.fileHash(uploadFile), util.fileSize(uploadFile)]);
|
|
|
|
|
uploadHash.should.equal(fileHash);
|
|
|
|
|
uploadSize.should.equal(fileSize);
|
|
|
|
|
|
|
|
|
|
// Verify the user's stats have been updated correctly
|
2018-07-26 19:40:42 -04:00
|
|
|
|
const userAfter = await User.findOne({username: username}, {_id: 0, uploadCount: 1, uploadSize: 1});
|
2018-07-26 16:54:08 -04:00
|
|
|
|
userAfter.uploadCount.should.equal(userBefore.uploadCount + 1);
|
|
|
|
|
userAfter.uploadSize.should.equal(userBefore.uploadSize + fileSize);
|
2018-07-26 13:17:37 -04:00
|
|
|
|
}
|
2018-07-25 21:34:16 -04:00
|
|
|
|
|
2018-07-26 19:40:42 -04:00
|
|
|
|
async function verifySuccessfulKeyUpload(key, file) {
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
async function verifyFailedUpload(file, status, message) {
|
2018-07-26 17:34:47 -04:00
|
|
|
|
const fileCountBefore = await util.directoryFileCount(config.get('Upload.path'));
|
2018-07-26 16:54:08 -04:00
|
|
|
|
const uploadCountBefore = await Upload.countDocuments({});
|
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
const res = await util.upload(file, agent);
|
|
|
|
|
res.should.have.status(status);
|
|
|
|
|
res.body.should.be.a('object');
|
|
|
|
|
res.body.should.have.property('message').equal(message);
|
2018-07-26 16:54:08 -04:00
|
|
|
|
|
2018-07-26 17:34:47 -04:00
|
|
|
|
const fileCountAfter = await util.directoryFileCount(config.get('Upload.path'));
|
2018-07-26 16:54:08 -04:00
|
|
|
|
fileCountAfter.should.equal(fileCountBefore, 'File should not have been written to disk');
|
|
|
|
|
|
|
|
|
|
const uploadCountAfter = await Upload.countDocuments({});
|
|
|
|
|
uploadCountAfter.should.equal(uploadCountBefore, 'No uploads should have been written to the database');
|
2018-07-26 13:17:37 -04:00
|
|
|
|
}
|
2018-07-25 21:34:16 -04:00
|
|
|
|
|
|
|
|
|
describe('0 Valid Request', () => {
|
2018-07-26 13:17:37 -04:00
|
|
|
|
it('SHOULD accept logged in valid upload', async () => {
|
|
|
|
|
await Promise.all([
|
|
|
|
|
util.createTestSession(agent),
|
|
|
|
|
util.createTestFile(2048, 'test.bin')
|
|
|
|
|
]);
|
|
|
|
|
|
2018-07-26 16:54:08 -04:00
|
|
|
|
await verifySuccessfulUpload('test.bin', 'user');
|
2018-07-26 13:17:37 -04:00
|
|
|
|
|
|
|
|
|
return Promise.all([
|
|
|
|
|
util.logout(agent),
|
2018-07-26 16:54:08 -04:00
|
|
|
|
util.deleteFile('test.bin')
|
2018-07-26 13:17:37 -04:00
|
|
|
|
]);
|
2018-07-25 21:34:16 -04:00
|
|
|
|
});
|
2017-10-13 16:17:18 -04:00
|
|
|
|
});
|
|
|
|
|
|
2018-07-25 21:34:16 -04:00
|
|
|
|
describe('1 Invalid Authentication', () => {
|
2018-07-26 13:17:37 -04:00
|
|
|
|
it('SHOULD NOT accept an unauthenticated request', async () =>
|
|
|
|
|
verifyFailedUpload(null, 401, 'Unauthorized.')
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
it('SHOULD NOT accept a request without file.upload scope', async () => {
|
2018-07-26 19:40:42 -04:00
|
|
|
|
await util.createInvite({code: 'code', scope: [], issuer: 'Mocha'});
|
|
|
|
|
await util.registerUser({displayname: 'user', password: 'pass', invite: 'code'}, agent);
|
|
|
|
|
await util.login({displayname: 'user', password: 'pass'}, agent);
|
2018-07-26 13:17:37 -04:00
|
|
|
|
|
|
|
|
|
await util.createTestFile(2048, 'test.bin');
|
|
|
|
|
|
|
|
|
|
await verifyFailedUpload('test.bin', 403, 'Forbidden.');
|
2017-10-13 16:17:18 -04:00
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
return Promise.all([
|
|
|
|
|
util.logout(agent),
|
2018-07-26 16:54:08 -04:00
|
|
|
|
util.deleteFile('test.bin')
|
2018-07-26 13:17:37 -04:00
|
|
|
|
]);
|
2018-07-25 21:34:16 -04:00
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe('3 Invalid File', () => {
|
2018-07-26 13:17:37 -04:00
|
|
|
|
it('SHOULD NOT accept a too large file', async () => {
|
|
|
|
|
await Promise.all([
|
|
|
|
|
util.createTestSession(agent),
|
2018-07-26 17:34:47 -04:00
|
|
|
|
util.createTestFile(config.get('Upload.maxSize') + 1, 'large.bin')
|
2018-07-26 13:17:37 -04:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
await verifyFailedUpload('large.bin', 413, 'File too large.');
|
|
|
|
|
|
|
|
|
|
return Promise.all([
|
|
|
|
|
util.logout(agent),
|
2018-07-26 16:54:08 -04:00
|
|
|
|
util.deleteFile('large.bin')
|
2018-07-26 13:17:37 -04:00
|
|
|
|
]);
|
|
|
|
|
});
|
2017-10-13 18:08:13 -04:00
|
|
|
|
});
|
2017-10-13 16:17:18 -04:00
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
describe('4 Invalid Request', () => {
|
|
|
|
|
it('SHOULD NOT accept a request with no file attached', async () => {
|
|
|
|
|
await util.createTestSession(agent);
|
|
|
|
|
await verifyFailedUpload(null, 400, 'No file specified.');
|
2018-07-25 21:34:16 -04:00
|
|
|
|
|
2018-07-26 13:17:37 -04:00
|
|
|
|
return util.logout(agent);
|
|
|
|
|
})
|
|
|
|
|
})
|
2017-10-13 16:17:18 -04:00
|
|
|
|
});
|
2018-07-26 13:17:37 -04:00
|
|
|
|
});
|
2017-10-13 16:17:18 -04:00
|
|
|
|
|
2018-07-25 18:45:38 -04:00
|
|
|
|
after(() => server.close(() => process.exit(0)));
|