feat: add snjs package

This commit is contained in:
Karol Sójko
2022-07-06 14:04:18 +02:00
parent 321a055bae
commit 0e40469e2f
296 changed files with 46109 additions and 187 deletions

View File

@@ -0,0 +1,15 @@
module.exports = {
extends: ['../.eslintrc.js'],
globals: {
chai: true,
chaiAsPromised: true,
describe: true,
beforeEach: true,
localStorage: true,
it: true,
afterEach: true,
ContentType: true,
fetch: true,
ClientDisplayableError: true,
},
}

View File

@@ -0,0 +1,36 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('000 legacy protocol operations', () => {
const application = Factory.createApplicationWithRealCrypto()
const protocol004 = new SNProtocolOperator004(new SNWebCrypto())
before(async () => {
await Factory.initializeApplication(application)
})
after(async () => {
await Factory.safeDeinit(application)
})
it('cannot decode 000 item', function () {
const string =
'000eyJyZWZlcmVuY2VzIjpbeyJ1dWlkIjoiZGMwMDUwZWUtNWQyNi00MGMyLWJjMjAtYzU1ZWE1Yjc4MmUwIiwiY29udGVudF90eXBlIjoiU058VXNlclByZWZlcmVuY2VzIn1dLCJhcHBEYXRhIjp7Im9yZy5zdGFuZGFyZG5vdGVzLnNuIjp7ImNsaWVudF91cGRhdGVkX2F0IjoiMjAyMC0wNC0wOFQxNDoxODozNC4yNzBaIn19LCJ0aXRsZSI6IjAuMDMyMzc3OTQyMDUxNzUzMzciLCJ0ZXh0Ijoid29ybGQifQ=='
let error
try {
protocol004.generateDecryptedParametersSync({
uuid: 'foo',
content: string,
content_type: 'foo',
})
} catch (e) {
error = e
}
expect(error).to.be.ok
})
})

View File

@@ -0,0 +1,124 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('001 protocol operations', () => {
const application = Factory.createApplicationWithRealCrypto()
const protocol001 = new SNProtocolOperator001(new SNWebCrypto())
const _identifier = 'hello@test.com'
const _password = 'password'
let _keyParams, _key
// runs once before all tests in this block
before(async () => {
localStorage.clear()
await Factory.initializeApplication(application)
_key = await protocol001.createRootKey(_identifier, _password, KeyParamsOrigination.Registration)
_keyParams = _key.keyParams
})
after(async () => {
await Factory.safeDeinit(application)
})
it('generates random key', async () => {
const length = 128
const key = await protocol001.crypto.generateRandomKey(length)
expect(key.length).to.equal(length / 4)
})
it('cost minimum', () => {
expect(application.protocolService.costMinimumForVersion('001')).to.equal(3000)
})
it('generates valid keys for registration', async () => {
const key = await protocol001.createRootKey(_identifier, _password, KeyParamsOrigination.Registration)
expect(key.serverPassword).to.be.ok
expect(key.masterKey).to.be.ok
expect(key.keyParams.content.pw_nonce).to.be.ok
expect(key.keyParams.content.pw_cost).to.be.ok
expect(key.keyParams.content.pw_salt).to.be.ok
})
it('generates valid keys from existing params and decrypts', async () => {
const password = 'password'
const keyParams = await application.protocolService.createKeyParams({
pw_func: 'pbkdf2',
pw_alg: 'sha512',
pw_key_size: 512,
pw_cost: 5000,
pw_salt: '45cf889386d7ed72a0dcfb9d06fee9f6274ec0ce',
})
const key = await protocol001.computeRootKey(password, keyParams)
expect(key.keyVersion).to.equal('001')
expect(key.serverPassword).to.equal('8f2f0513e90648c08ef6fa55eda00bb76e82dfdc2e218e4338b6246e0f68eb78')
expect(key.masterKey).to.equal('65e040f8ef6775fecbb7ee5599ec3f059faa96d728e50f2014237a802ac5bd0f')
expect(key.dataAuthenticationKey).to.not.be.ok
const payload = new EncryptedPayload({
auth_hash: '0ae7e3c9fce61f07a8d5d267accab20793a06ab266c245fe59178d49c1ad3fa6',
content:
'001hEIgw837WzFM7Eb5tBHHXumxxKwaWuDv5hyhmrNDTUU5qxnb5jkjo1HsRzw+Z65BMuDqIdHlZU3plW+4QpJ6iFksFPYgo8VHa++dOtfAP7Q=',
content_type: 'Note',
enc_item_key:
'sVuHmG0XAp1PRDE8r8XqFXijjP8Pqdwal9YFRrXK4hKLt1yyq8MwQU+1Z95Tz/b7ajYdidwFE0iDwd8Iu8281VtJsQ4yhh2tJiAzBy6newyHfhA5nH93yZ3iXRJaG87bgNQE9lsXzTV/OHAvqMuQtw/QVSWI3Qy1Pyu1Tn72q7FPKKhRRkzEEZ+Ax0BA1fHg',
uuid: '54001a6f-7c22-4b34-8316-fadf9b1fc255',
})
const decrypted = await application.protocolService.decryptSplitSingle({
usesRootKey: {
items: [payload],
key: key,
},
})
expect(decrypted.errorDecrypting).to.not.be.ok
expect(decrypted.content.text).to.equal('Decryptable Sentence')
})
it('properly encrypts and decrypts', async () => {
const text = 'hello world'
const key = _key.masterKey
const encString = await protocol001.encryptString(text, key)
const decString = await protocol001.decryptString(encString, key)
expect(decString).to.equal(text)
})
it('generates existing keys for key params', async () => {
const key = await protocol001.computeRootKey(_password, _keyParams)
expect(key.content).to.have.property('serverPassword')
expect(key.content).to.have.property('masterKey')
expect(key.compare(_key)).to.be.true
})
it('generating encryption params includes items_key_id', async () => {
const payload = Factory.createNotePayload()
const key = await protocol001.createItemsKey()
const params = await protocol001.generateEncryptedParametersAsync(payload, key)
expect(params.content).to.be.ok
expect(params.enc_item_key).to.be.ok
expect(params.auth_hash).to.be.ok
expect(params.items_key_id).to.equal(key.uuid)
})
it('can decrypt encrypted params', async () => {
const payload = Factory.createNotePayload()
const key = await protocol001.createItemsKey()
const params = await protocol001.generateEncryptedParametersAsync(payload, key)
const decrypted = await protocol001.generateDecryptedParametersAsync(params, key)
expect(decrypted.content).to.eql(payload.content)
})
it('payloads missing enc_item_key should decrypt as errorDecrypting', async () => {
const payload = Factory.createNotePayload()
const key = await protocol001.createItemsKey()
const params = await protocol001.generateEncryptedParametersAsync(payload, key)
const modified = new EncryptedPayload({
...params,
enc_item_key: undefined,
})
const decrypted = await protocol001.generateDecryptedParametersAsync(modified, key)
expect(decrypted.errorDecrypting).to.equal(true)
})
})

View File

@@ -0,0 +1,120 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('002 protocol operations', () => {
const _identifier = 'hello@test.com'
const _password = 'password'
let _keyParams, _key
const application = Factory.createApplicationWithRealCrypto()
const protocol002 = new SNProtocolOperator002(new SNWebCrypto())
// runs once before all tests in this block
before(async () => {
localStorage.clear()
await Factory.initializeApplication(application)
_key = await protocol002.createRootKey(_identifier, _password, KeyParamsOrigination.Registration)
_keyParams = _key.keyParams
})
after(async () => {
await Factory.safeDeinit(application)
})
it('generates random key', async () => {
const length = 128
const key = await protocol002.crypto.generateRandomKey(length)
expect(key.length).to.equal(length / 4)
})
it('cost minimum', () => {
expect(application.protocolService.costMinimumForVersion('002')).to.equal(3000)
})
it('generates valid keys for registration', async () => {
const key = await protocol002.createRootKey(_identifier, _password, KeyParamsOrigination.Registration)
expect(key.dataAuthenticationKey).to.be.ok
expect(key.serverPassword).to.be.ok
expect(key.masterKey).to.be.ok
expect(key.keyParams.content.pw_nonce).to.be.ok
expect(key.keyParams.content.pw_cost).to.be.ok
expect(key.keyParams.content.pw_salt).to.be.ok
})
it('generates valid keys from existing params and decrypts', async () => {
const password = 'password'
const keyParams = await application.protocolService.createKeyParams({
pw_salt: '8d381ef44cdeab1489194f87066b747b46053a833ee24956e846e7b40440f5f4',
pw_cost: 101000,
version: '002',
})
const key = await protocol002.computeRootKey(password, keyParams)
expect(key.keyVersion).to.equal('002')
expect(key.serverPassword).to.equal('f3cc7efc93380a7a3765dcb0498dabe83387acdda78f43bc7cfc31f4a2a05077')
expect(key.masterKey).to.equal('66500f7c9fb8ba0843e13e2f555feb5e43a3c27fee23e9b900a2577f1b373e1a')
expect(key.dataAuthenticationKey).to.equal('af3d6a7fd6c0422a7a84b0e99d6ac2a79b77675c9848f74314c20046e1f95c75')
const payload = new EncryptedPayload({
content:
'002:0ff292a79549e817003886e9c4865eaf5faa0b3ada5b41c846c63bd4056e6816:959b042a-3892-461e-8c50-477c10c7c40a:c856f9d81033994f397285e2d060e9d4:pQ/jKyb8qCsz18jdMiYkpxf4l8ELIbTtwqUwLM3fRUwDL4/ofZLGICuFlssmrb74Brm+N19znwfNQ9ouFPtijA==',
content_type: 'Note',
enc_item_key:
'002:24a8e8f7728bbe06605d8209d87ad338d3d15ef81154bb64d3967c77daa01333:959b042a-3892-461e-8c50-477c10c7c40a:f1d294388742dca34f6f266a01483a4e:VdlEDyjhZ35GbJDg8ruSZv3Tp6WtMME3T5LLvcBYLHIMhrMi0RlPK83lK6F0aEaZvY82pZ0ntU+XpAX7JMSEdKdPXsACML7WeFrqKb3z2qHnA7NxgnIC0yVT/Z2mRrvlY3NNrUPGwJbfRcvfS7FVyw87MemT9CSubMZRviXvXETx82t7rsgjV/AIwOOeWhFi',
uuid: '959b042a-3892-461e-8c50-477c10c7c40a',
})
const decrypted = await application.protocolService.decryptSplitSingle({
usesRootKey: {
items: [payload],
key: key,
},
})
expect(decrypted.errorDecrypting).to.not.be.ok
expect(decrypted.content.text).to.equal('Decryptable Sentence')
})
it('properly encrypts and decrypts strings', async () => {
const text = 'hello world'
const key = _key.masterKey
const iv = await protocol002.crypto.generateRandomKey(128)
const encString = await protocol002.encryptString002(text, key, iv)
const decString = await protocol002.decryptString002(encString, key, iv)
expect(decString).to.equal(text)
})
it('generates existing keys for key params', async () => {
const key = await protocol002.computeRootKey(_password, _keyParams)
expect(key.compare(_key)).to.be.true
})
it('generating encryption params includes items_key_id', async () => {
const payload = Factory.createNotePayload()
const key = await protocol002.createItemsKey()
const params = await protocol002.generateEncryptedParametersAsync(payload, key)
expect(params.content).to.be.ok
expect(params.enc_item_key).to.be.ok
expect(params.items_key_id).to.equal(key.uuid)
})
it('can decrypt encrypted params', async () => {
const payload = Factory.createNotePayload()
const key = await protocol002.createItemsKey()
const params = await protocol002.generateEncryptedParametersAsync(payload, key)
const decrypted = await protocol002.generateDecryptedParametersAsync(params, key)
expect(decrypted.content).to.eql(payload.content)
})
it('payloads missing enc_item_key should decrypt as errorDecrypting', async () => {
const payload = Factory.createNotePayload()
const key = await protocol002.createItemsKey()
const params = await protocol002.generateEncryptedParametersAsync(payload, key)
const modified = new EncryptedPayload({
...params,
enc_item_key: undefined,
})
const decrypted = await protocol002.generateDecryptedParametersAsync(modified, key)
expect(decrypted.errorDecrypting).to.equal(true)
})
})

View File

@@ -0,0 +1,125 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('003 protocol operations', () => {
before(async () => {
localStorage.clear()
})
after(async () => {
localStorage.clear()
})
const _identifier = 'hello@test.com'
const _password = 'password'
let _keyParams, _key
const sharedApplication = Factory.createApplicationWithRealCrypto()
const protocol003 = new SNProtocolOperator003(new SNWebCrypto())
// runs once before all tests in this block
before(async () => {
await Factory.initializeApplication(sharedApplication)
_key = await protocol003.createRootKey(_identifier, _password, KeyParamsOrigination.Registration)
_keyParams = _key.keyParams
})
after(async () => {
await Factory.safeDeinit(sharedApplication)
})
it('generates random key', async () => {
const length = 128
const key = await protocol003.crypto.generateRandomKey(length)
expect(key.length).to.equal(length / 4)
})
it('cost minimum should throw', () => {
expect(() => {
sharedApplication.protocolService.costMinimumForVersion('003')
}).to.throw('Cost minimums only apply to versions <= 002')
})
it('generates valid keys for registration', async () => {
const key = await protocol003.createRootKey(_identifier, _password, KeyParamsOrigination.Registration)
expect(key.dataAuthenticationKey).to.be.ok
expect(key.serverPassword).to.be.ok
expect(key.masterKey).to.be.ok
expect(key.keyParams.content.pw_nonce).to.be.ok
expect(key.keyParams.content.pw_cost).to.not.be.ok
expect(key.keyParams.content.pw_salt).to.not.be.ok
expect(key.keyParams.content.identifier).to.be.ok
})
it('computes proper keys for sign in', async () => {
const identifier = 'foo@bar.com'
const password = 'very_secure'
const keyParams = sharedApplication.protocolService.createKeyParams({
pw_nonce: 'baaec0131d677cf993381367eb082fe377cefe70118c1699cb9b38f0bc850e7b',
identifier: identifier,
version: '003',
})
const key = await protocol003.computeRootKey(password, keyParams)
expect(key.serverPassword).to.equal('60fdae231049d81974c562e943ad472f0143daa87f43048d2ede2d199ea7be25')
expect(key.masterKey).to.equal('2b2162e5299f71f9fcd39789a01f6062f2779220e97a43d7895cf30da11186e9')
expect(key.dataAuthenticationKey).to.equal('24dfba6f42ffc07a5223440a28a574d463e99d8d4aeb68fe95f55aa8ed5fd39f')
})
it('can decrypt item generated with web version 3.3.6', async () => {
const identifier = 'demo@standardnotes.org'
const password = 'password'
const keyParams = sharedApplication.protocolService.createKeyParams({
pw_nonce: '31107837b44d86179140b7c602a55d694243e2e9ced0c4c914ac21ad90215055',
identifier: identifier,
version: '003',
})
const key = await protocol003.computeRootKey(password, keyParams)
const payload = new EncryptedPayload({
uuid: '80488ade-933a-4570-8852-5282a094fafc',
content_type: 'Note',
enc_item_key:
'003:f385f1af03c6e16844ba685b0766a93f65c6e1813c56146376994188c40902ef:80488ade-933a-4570-8852-5282a094fafc:8af48228d965847a3fb7904e801f3958:xM1UKtXEpXytu0amQ405rpJ8KvTcNNjqNcZEWfhefZQo+25cZfNgFRniZuO2ysXyR4qWiLQlWb5pptQi1gSNakOmCNl7WiQgH7t7ia7gwz667i6nrrwVJ8vauXeyTzspr4J/NHa1LM/f8/MDxiHWVG7MvXkWqGT7qBCzcY1BXXQaMlf6g1VEDq+INPnzSZG/:eyJpZGVudGlmaWVyIjoiZGVtb0BzdGFuZGFyZG5vdGVzLm9yZyIsInB3X2Nvc3QiOjExMDAwMCwicHdfbm9uY2UiOiIzMTEwNzgzN2I0NGQ4NjE3OTE0MGI3YzYwMmE1NWQ2OTQyNDNlMmU5Y2VkMGM0YzkxNGFjMjFhZDkwMjE1MDU1IiwidmVyc2lvbiI6IjAwMyJ9',
content:
'003:ca505a223d3ef3ad5cd4e6f4e0d06a2bb34b8b032f60180165c37acd5a4718e3:80488ade-933a-4570-8852-5282a094fafc:bad25bb4ba935646148fe7f118c5f60d:g+eHtGG+M4ZdIevpx9xkK9mmFYo8/1JTlaDysM18nGrA3Oe3wvFTfG5PPvH50uY6PgBbWZPS+BNpsH/gVMH8T9LCreRLPVw5yRhunyva0pgsk/k4Dmi4PTsvvNqhA2F8X2LZTwuw7QlLkvOneX9cNmNDzVGmsedSWhEZXbD5jmb1Ev77Gq1kjqh2eFc7lPa/WBb52fs8FHKbO9HUGqXF49/JOunpvp76/bAydavGQ2n/abkGCoYvrtmyM1lqthBb8w60KidkC/Hm4cGAm8wNKyg58YUHCYPAlaUI0DxPGXu24Ur/6M7HdP/9puitJGUSlXA32DXABMd8DbUk6JPvJRKvQ/v4Dd3UR0h7Gdm/YME=:eyJpZGVudGlmaWVyIjoiZGVtb0BzdGFuZGFyZG5vdGVzLm9yZyIsInB3X2Nvc3QiOjExMDAwMCwicHdfbm9uY2UiOiIzMTEwNzgzN2I0NGQ4NjE3OTE0MGI3YzYwMmE1NWQ2OTQyNDNlMmU5Y2VkMGM0YzkxNGFjMjFhZDkwMjE1MDU1IiwidmVyc2lvbiI6IjAwMyJ9',
})
const decrypted = await protocol003.generateDecryptedParametersAsync(payload, key)
expect(decrypted.content.title).to.equal('Secret key')
expect(decrypted.content.text).to.equal('TaW8uq4cZRCNf3e4L8c7xFhsJkJdt6')
})
it('properly encrypts and decrypts', async () => {
const text = 'hello world'
const rawKey = _key.masterKey
const iv = await protocol003.crypto.generateRandomKey(128)
const encString = await protocol003.encryptString002(text, rawKey, iv)
const decString = await protocol003.decryptString002(encString, rawKey, iv)
expect(decString).to.equal(text)
})
it('generates existing keys for key params', async () => {
const key = await protocol003.computeRootKey(_password, _keyParams)
expect(key.compare(_key)).to.be.true
})
it('generating encryption params includes items_key_id', async () => {
const payload = Factory.createNotePayload()
const key = await protocol003.createItemsKey()
const params = await protocol003.generateEncryptedParametersAsync(payload, key)
expect(params.content).to.be.ok
expect(params.enc_item_key).to.be.ok
expect(params.items_key_id).to.equal(key.uuid)
})
it('can decrypt encrypted params', async () => {
const payload = Factory.createNotePayload()
const key = await protocol003.createItemsKey()
const params = await protocol003.generateEncryptedParametersAsync(payload, key)
const decrypted = await protocol003.generateDecryptedParametersAsync(params, key)
expect(decrypted.content).to.eql(payload.content)
})
})

View File

@@ -0,0 +1,121 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('004 protocol operations', function () {
const _identifier = 'hello@test.com'
const _password = 'password'
let _keyParams
let _key
const application = Factory.createApplicationWithRealCrypto()
const protocol004 = new SNProtocolOperator004(new SNWebCrypto())
before(async function () {
await Factory.initializeApplication(application)
_key = await protocol004.createRootKey(_identifier, _password, KeyParamsOrigination.Registration)
_keyParams = _key.keyParams
})
after(async function () {
await Factory.safeDeinit(application)
})
it('cost minimum should throw', function () {
expect(function () {
application.protocolService.costMinimumForVersion('004')
}).to.throw('Cost minimums only apply to versions <= 002')
})
it('generates valid keys for registration', async function () {
const key = await application.protocolService.createRootKey(
_identifier,
_password,
KeyParamsOrigination.Registration,
)
expect(key.masterKey).to.be.ok
expect(key.serverPassword).to.be.ok
expect(key.mk).to.not.be.ok
expect(key.dataAuthenticationKey).to.not.be.ok
expect(key.keyParams.content004.pw_nonce).to.be.ok
expect(key.keyParams.content004.pw_cost).to.not.be.ok
expect(key.keyParams.content004.salt).to.not.be.ok
expect(key.keyParams.content004.identifier).to.be.ok
})
it('computes proper keys for sign in', async function () {
const identifier = 'foo@bar.com'
const password = 'very_secure'
const keyParams = application.protocolService.createKeyParams({
pw_nonce: 'baaec0131d677cf993381367eb082fe377cefe70118c1699cb9b38f0bc850e7b',
identifier: identifier,
version: '004',
})
const key = await protocol004.computeRootKey(password, keyParams)
expect(key.masterKey).to.equal('5d68e78b56d454e32e1f5dbf4c4e7cf25d74dc1efc942e7c9dfce572c1f3b943')
expect(key.serverPassword).to.equal('83707dfc837b3fe52b317be367d3ed8e14e903b2902760884fd0246a77c2299d')
expect(key.dataAuthenticationKey).to.not.be.ok
})
it('generates random key', async function () {
const length = 96
const key = await application.protocolService.crypto.generateRandomKey(length)
expect(key.length).to.equal(length / 4)
})
it('properly encrypts and decrypts', async function () {
const text = 'hello world'
const rawKey = _key.masterKey
const nonce = await application.protocolService.crypto.generateRandomKey(192)
const operator = application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V004)
const authenticatedData = { foo: 'bar' }
const encString = await operator.encryptString004(text, rawKey, nonce, authenticatedData)
const decString = await operator.decryptString004(
encString,
rawKey,
nonce,
await operator.authenticatedDataToString(authenticatedData),
)
expect(decString).to.equal(text)
})
it('fails to decrypt non-matching aad', async function () {
const text = 'hello world'
const rawKey = _key.masterKey
const nonce = await application.protocolService.crypto.generateRandomKey(192)
const operator = application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V004)
const aad = { foo: 'bar' }
const nonmatchingAad = { foo: 'rab' }
const encString = await operator.encryptString004(text, rawKey, nonce, aad)
const decString = await operator.decryptString004(encString, rawKey, nonce, nonmatchingAad)
expect(decString).to.not.be.ok
})
it('generates existing keys for key params', async function () {
const key = await application.protocolService.computeRootKey(_password, _keyParams)
expect(key.compare(_key)).to.be.true
})
it('can decrypt encrypted params', async function () {
const payload = Factory.createNotePayload()
const key = await protocol004.createItemsKey()
const params = await protocol004.generateEncryptedParametersSync(payload, key)
const decrypted = await protocol004.generateDecryptedParametersSync(params, key)
expect(decrypted.errorDecrypting).to.not.be.ok
expect(decrypted.content).to.eql(payload.content)
})
it('modifying the uuid of the payload should fail to decrypt', async function () {
const payload = Factory.createNotePayload()
const key = await protocol004.createItemsKey()
const params = await protocol004.generateEncryptedParametersSync(payload, key)
params.uuid = 'foo'
const result = await protocol004.generateDecryptedParametersSync(params, key)
expect(result.errorDecrypting).to.equal(true)
})
})

View File

@@ -0,0 +1,442 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
import * as Utils from './lib/Utils.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('actions service', () => {
const errorProcessingActionMessage = 'An issue occurred while processing this action. Please try again.'
before(async function () {
this.timeout(20000)
localStorage.clear()
this.application = await Factory.createInitAppWithFakeCrypto()
this.itemManager = this.application.itemManager
this.actionsManager = this.application.actionsManager
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const rootKey = await this.application.protocolService.createRootKey(
this.email,
this.password,
KeyParamsOrigination.Registration,
)
this.authParams = rootKey.keyParams.content
this.fakeServer = sinon.fakeServer.create()
this.fakeServer.respondImmediately = true
this.actionsExtension = {
identifier: 'org.standardnotes.testing',
name: 'Test extension',
content_type: 'Extension',
url: 'http://my-extension.sn.org/get_actions/',
description: 'For testing purposes.',
supported_types: ['Note'],
actions: [
{
label: 'Action #1',
url: 'http://my-extension.sn.org/action_1/',
verb: 'get',
context: '*',
content_types: ['Note'],
},
{
label: 'Action #2',
url: 'http://my-extension.sn.org/action_2/',
verb: 'render',
context: 'Note',
content_types: ['Note'],
},
{
label: 'Action #3',
url: 'http://my-extension.sn.org/action_3/',
verb: 'show',
context: 'Tag',
content_types: ['Note'],
},
{
label: 'Action #5',
url: 'http://my-extension.sn.org/action_5/',
verb: 'render',
context: 'Note',
content_types: ['Note'],
},
{
label: 'Action #7',
url: 'http://my-extension.sn.org/action_7/',
verb: 'nested',
context: 'Note',
content_types: ['Note'],
},
],
}
this.fakeServer.respondWith('GET', /http:\/\/my-extension.sn.org\/get_actions\/(.*)/, (request, params) => {
const urlParams = new URLSearchParams(params)
const extension = Copy(this.actionsExtension)
if (urlParams.has('item_uuid')) {
extension.actions.push({
label: 'Action #4',
url: `http://my-extension.sn.org/action_4/?item_uuid=${urlParams.get('item_uuid')}`,
verb: 'post',
context: 'Item',
content_types: ['Note'],
access_type: 'decrypted',
})
extension.actions.push({
label: 'Action #6',
url: `http://my-extension.sn.org/action_6/?item_uuid=${urlParams.get('item_uuid')}`,
verb: 'post',
context: 'Item',
content_types: ['Note'],
access_type: 'encrypted',
})
}
request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify(extension))
})
const payload = new DecryptedPayload({
uuid: Utils.generateUuid(),
content_type: ContentType.Note,
content: {
title: 'Testing',
},
})
const encryptedPayload = CreateEncryptedServerSyncPushPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
this.fakeServer.respondWith('GET', /http:\/\/my-extension.sn.org\/action_[1,2]\/(.*)/, (request) => {
request.respond(
200,
{ 'Content-Type': 'application/json' },
JSON.stringify({
item: encryptedPayload,
auth_params: this.authParams,
}),
)
})
this.fakeServer.respondWith('GET', 'http://my-extension.sn.org/action_3/', [
200,
{ 'Content-Type': 'text/html; charset=utf-8' },
'<h2>Action #3</h2>',
])
this.fakeServer.respondWith('POST', /http:\/\/my-extension.sn.org\/action_[4,6]\/(.*)/, (request) => {
const requestBody = JSON.parse(request.requestBody)
const response = {
uuid: requestBody.items[0].uuid,
result: 'Action POSTed successfully.',
}
request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify(response))
})
this.fakeServer.respondWith('GET', 'http://my-extension.sn.org/action_5/', (request) => {
const encryptedPayloadClone = Copy(encryptedPayload)
encryptedPayloadClone.items_key_id = undefined
encryptedPayloadClone.content = '003:somenonsense'
encryptedPayloadClone.enc_item_key = '003:anothernonsense'
encryptedPayloadClone.version = '003'
encryptedPayloadClone.uuid = 'fake-uuid'
const payload = {
item: encryptedPayloadClone,
auth_params: this.authParams,
}
request.respond(200, { 'Content-Type': 'application/json' }, JSON.stringify(payload))
})
// Extension item
const extensionItem = await this.application.itemManager.createItem(
ContentType.ActionsExtension,
this.actionsExtension,
)
this.extensionItemUuid = extensionItem.uuid
})
after(async function () {
this.fakeServer.restore()
await Factory.safeDeinit(this.application)
this.application = null
localStorage.clear()
})
it('should get extension items', async function () {
await this.itemManager.createItem(ContentType.Note, {
title: 'A simple note',
text: 'Standard Notes rocks! lml.',
})
const extensions = this.actionsManager.getExtensions()
expect(extensions.length).to.eq(1)
})
it('should get extensions in context of item', async function () {
const noteItem = await this.itemManager.createItem(ContentType.Note, {
title: 'Another note',
text: 'Whiskey In The Jar',
})
const noteItemExtensions = this.actionsManager.extensionsInContextOfItem(noteItem)
expect(noteItemExtensions.length).to.eq(1)
expect(noteItemExtensions[0].supported_types).to.include(noteItem.content_type)
})
it('should get actions based on item context', async function () {
const tagItem = await this.itemManager.createItem(ContentType.Tag, {
title: 'Music',
})
const extensionItem = await this.itemManager.findItem(this.extensionItemUuid)
const tagActions = extensionItem.actionsWithContextForItem(tagItem)
expect(tagActions.length).to.eq(1)
expect(tagActions.map((action) => action.label)).to.have.members(['Action #3'])
})
it('should load extension in context of item', async function () {
const noteItem = await this.itemManager.createItem(ContentType.Note, {
title: 'Yet another note',
text: 'And all things will end ♫',
})
const extensionItem = await this.itemManager.findItem(this.extensionItemUuid)
expect(extensionItem.actions.length).to.be.eq(5)
const extensionWithItem = await this.actionsManager.loadExtensionInContextOfItem(extensionItem, noteItem)
expect(extensionWithItem.actions.length).to.be.eq(7)
expect(extensionWithItem.actions.map((action) => action.label)).to.include.members([
/**
* These actions were returned from the server
* and are relevant for the current item only.
*/
'Action #4',
'Action #6',
])
// Actions that are relevant for an item should not be stored.
const updatedExtensionItem = await this.itemManager.findItem(this.extensionItemUuid)
const expectedActions = extensionItem.actions.map((action) => {
const { id, ...rest } = action
return rest
})
expect(updatedExtensionItem.actions).to.containSubset(expectedActions)
})
describe('render action', async function () {
const sandbox = sinon.createSandbox()
before(async function () {
this.noteItem = await this.itemManager.createItem(ContentType.Note, {
title: 'Hey',
text: 'Welcome To Paradise',
})
const extensionItem = await this.itemManager.findItem(this.extensionItemUuid)
this.renderAction = extensionItem.actions.filter((action) => action.verb === 'render')[0]
})
beforeEach(async function () {
this.alertServiceAlert = sandbox.spy(this.actionsManager.alertService, 'alert')
this.windowAlert = sandbox.stub(window, 'alert').callsFake((message) => message)
})
afterEach(async function () {
sandbox.restore()
})
it('should show an alert if the request fails', async function () {
this.httpServiceGetAbsolute = sandbox
.stub(this.actionsManager.httpService, 'getAbsolute')
.callsFake((url) => Promise.reject(new Error('Dummy error.')))
const actionResponse = await this.actionsManager.runAction(this.renderAction, this.noteItem)
sinon.assert.calledOnceWithExactly(this.httpServiceGetAbsolute, this.renderAction.url)
sinon.assert.calledOnceWithExactly(this.alertServiceAlert, errorProcessingActionMessage)
expect(actionResponse.error.message).to.eq(errorProcessingActionMessage)
})
it('should return a response if payload is valid', async function () {
const actionResponse = await this.actionsManager.runAction(this.renderAction, this.noteItem)
expect(actionResponse).to.have.property('item')
expect(actionResponse.item.payload.content.title).to.eq('Testing')
})
it('should return undefined if payload is invalid', async function () {
sandbox.stub(this.actionsManager, 'payloadByDecryptingResponse').returns(null)
const actionResponse = await this.actionsManager.runAction(this.renderAction, this.noteItem)
expect(actionResponse).to.be.undefined
})
it('should return decrypted payload if password is valid', async function () {
const extensionItem = await this.itemManager.findItem(this.extensionItemUuid)
this.renderAction = extensionItem.actions.filter((action) => action.verb === 'render')[0]
const actionResponse = await this.actionsManager.runAction(this.renderAction, this.noteItem)
expect(actionResponse.item).to.be.ok
expect(actionResponse.item.title).to.be.equal('Testing')
}).timeout(20000)
})
describe('show action', async function () {
const sandbox = sinon.createSandbox()
before(async function () {
const extensionItem = await this.itemManager.findItem(this.extensionItemUuid)
this.showAction = extensionItem.actions[2]
})
beforeEach(async function () {
this.actionsManager.deviceInterface.openUrl = (url) => url
this.deviceInterfaceOpenUrl = sandbox.spy(this.actionsManager.deviceInterface, 'openUrl')
})
this.afterEach(async function () {
sandbox.restore()
})
it('should open the action url', async function () {
const response = await this.actionsManager.runAction(this.showAction)
sandbox.assert.calledOnceWithExactly(this.deviceInterfaceOpenUrl, this.showAction.url)
expect(response).to.eql({})
})
})
describe('post action', async function () {
const sandbox = sinon.createSandbox()
before(async function () {
this.noteItem = await this.itemManager.createItem(ContentType.Note, {
title: 'Excuse Me',
text: 'Time To Be King 8)',
})
this.extensionItem = await this.itemManager.findItem(this.extensionItemUuid)
this.extensionItem = await this.actionsManager.loadExtensionInContextOfItem(this.extensionItem, this.noteItem)
this.decryptedPostAction = this.extensionItem.actions.filter(
(action) => action.access_type === 'decrypted' && action.verb === 'post',
)[0]
this.encryptedPostAction = this.extensionItem.actions.filter(
(action) => action.access_type === 'encrypted' && action.verb === 'post',
)[0]
})
beforeEach(async function () {
this.alertServiceAlert = sandbox.spy(this.actionsManager.alertService, 'alert')
this.windowAlert = sandbox.stub(window, 'alert').callsFake((message) => message)
this.httpServicePostAbsolute = sandbox.stub(this.actionsManager.httpService, 'postAbsolute')
this.httpServicePostAbsolute.callsFake((url, params) => Promise.resolve(params))
})
afterEach(async function () {
sandbox.restore()
})
it('should include generic encrypted payload within request body', async function () {
const response = await this.actionsManager.runAction(this.encryptedPostAction, this.noteItem)
expect(response.items[0].enc_item_key).to.satisfy((string) => {
return string.startsWith(this.application.protocolService.getLatestVersion())
})
expect(response.items[0].uuid).to.eq(this.noteItem.uuid)
expect(response.items[0].auth_hash).to.not.be.ok
expect(response.items[0].content_type).to.be.ok
expect(response.items[0].created_at).to.be.ok
expect(response.items[0].content).to.satisfy((string) => {
return string.startsWith(this.application.protocolService.getLatestVersion())
})
})
it('should include generic decrypted payload within request body', async function () {
const response = await this.actionsManager.runAction(this.decryptedPostAction, this.noteItem)
expect(response.items[0].uuid).to.eq(this.noteItem.uuid)
expect(response.items[0].enc_item_key).to.not.be.ok
expect(response.items[0].auth_hash).to.not.be.ok
expect(response.items[0].content_type).to.be.ok
expect(response.items[0].created_at).to.be.ok
expect(response.items[0].content.title).to.eq(this.noteItem.title)
expect(response.items[0].content.text).to.eq(this.noteItem.text)
})
it('should post to the action url', async function () {
this.httpServicePostAbsolute.restore()
const response = await this.actionsManager.runAction(this.decryptedPostAction, this.noteItem)
expect(response).to.be.ok
expect(response.uuid).to.eq(this.noteItem.uuid)
expect(response.result).to.eq('Action POSTed successfully.')
})
it('should alert if an error occurred while processing the action', async function () {
this.httpServicePostAbsolute.restore()
const dummyError = new Error('Dummy error.')
sandbox
.stub(this.actionsManager.httpService, 'postAbsolute')
.callsFake((url, params) => Promise.reject(dummyError))
const response = await this.actionsManager.runAction(this.decryptedPostAction, this.noteItem)
sinon.assert.calledOnceWithExactly(this.alertServiceAlert, errorProcessingActionMessage)
expect(response).to.be.eq(dummyError)
})
})
describe('nested action', async function () {
const sandbox = sinon.createSandbox()
before(async function () {
const extensionItem = await this.itemManager.findItem(this.extensionItemUuid)
this.nestedAction = extensionItem.actions.filter((action) => action.verb === 'nested')[0]
})
beforeEach(async function () {
this.actionsManagerRunAction = sandbox.spy(this.actionsManager, 'runAction')
this.httpServiceRunHttp = sandbox.spy(this.actionsManager.httpService, 'runHttp')
this.actionResponse = await this.actionsManager.runAction(this.nestedAction)
})
afterEach(async function () {
sandbox.restore()
})
it('should return undefined', async function () {
expect(this.actionResponse).to.be.undefined
})
it('should call runAction once', async function () {
sandbox.assert.calledOnce(this.actionsManagerRunAction)
})
it('should not make any http requests', async function () {
sandbox.assert.notCalled(this.httpServiceRunHttp)
})
})
})

View File

@@ -0,0 +1,109 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import WebDeviceInterface from './lib/web_device_interface.js'
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('application group', function () {
const globalDevice = new WebDeviceInterface(setTimeout.bind(window), setInterval.bind(window))
beforeEach(async function () {
localStorage.clear()
})
afterEach(async function () {
localStorage.clear()
})
it('initializing a group should result with primary application', async function () {
const group = new SNApplicationGroup(globalDevice)
await group.initialize({
applicationCreator: (descriptor, deviceInterface) => {
return Factory.createApplicationWithFakeCrypto(descriptor.identifier, deviceInterface)
},
})
expect(group.primaryApplication).to.be.ok
expect(group.primaryApplication.identifier).to.be.ok
await Factory.safeDeinit(group.primaryApplication)
})
it('initializing a group should result with proper descriptor setup', async function () {
const group = new SNApplicationGroup(globalDevice)
await group.initialize({
applicationCreator: (descriptor, deviceInterface) => {
return Factory.createApplicationWithFakeCrypto(descriptor.identifier, deviceInterface)
},
})
const identifier = group.primaryApplication.identifier
expect(group.descriptorRecord[identifier].identifier).to.equal(identifier)
await Factory.safeDeinit(group.primaryApplication)
})
it('should persist descriptor record after changes', async function () {
const group = new SNApplicationGroup(globalDevice)
await group.initialize({
applicationCreator: (descriptor, device) => {
return Factory.createInitAppWithFakeCryptoWithOptions({
device: device,
identifier: descriptor.identifier,
})
},
})
const identifier = group.primaryApplication.identifier
const descriptorRecord = await group.device.getJsonParsedRawStorageValue(RawStorageKey.DescriptorRecord)
expect(descriptorRecord[identifier].identifier).to.equal(identifier)
expect(descriptorRecord[identifier].primary).to.equal(true)
await group.unloadCurrentAndCreateNewDescriptor()
const descriptorRecord2 = await globalDevice.getJsonParsedRawStorageValue(RawStorageKey.DescriptorRecord)
expect(Object.keys(descriptorRecord2).length).to.equal(2)
expect(descriptorRecord2[identifier].primary).to.equal(false)
})
it('adding new application should incrememnt total descriptor count', async function () {
const group = new SNApplicationGroup(globalDevice)
await group.initialize({
applicationCreator: (descriptor, device) => {
return Factory.createInitAppWithFakeCryptoWithOptions({
device: device,
identifier: descriptor.identifier,
})
},
})
await group.unloadCurrentAndCreateNewDescriptor()
expect(group.getDescriptors().length).to.equal(2)
})
it('should be notified when application changes', async function () {
const group = new SNApplicationGroup(globalDevice)
let notifyCount = 0
const expectedCount = 2
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve) => {
group.addEventObserver(() => {
notifyCount++
if (notifyCount === expectedCount) {
resolve()
}
})
await group.initialize({
applicationCreator: (descriptor, device) => {
return Factory.createInitAppWithFakeCryptoWithOptions({
device: device,
identifier: descriptor.identifier,
})
},
})
await group.unloadCurrentAndCreateNewDescriptor()
}).then(() => {
expect(notifyCount).to.equal(expectedCount)
})
}).timeout(1000)
})

View File

@@ -0,0 +1,183 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('application instances', () => {
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
beforeEach(async () => {
localStorage.clear()
})
afterEach(async () => {
localStorage.clear()
})
it('two distinct applications should not share model manager state', async () => {
const app1 = await Factory.createAndInitializeApplication('app1')
const app2 = await Factory.createAndInitializeApplication('app2')
expect(app1.payloadManager).to.equal(app1.payloadManager)
expect(app1.payloadManager).to.not.equal(app2.payloadManager)
await Factory.createMappedNote(app1)
expect(app1.itemManager.items.length).length.to.equal(BASE_ITEM_COUNT + 1)
expect(app2.itemManager.items.length).to.equal(BASE_ITEM_COUNT)
await Factory.safeDeinit(app1)
await Factory.safeDeinit(app2)
})
it('two distinct applications should not share storage manager state', async () => {
const app1 = await Factory.createAndInitializeApplication('app1')
const app2 = await Factory.createAndInitializeApplication('app2')
await Factory.createMappedNote(app1)
await app1.syncService.sync(syncOptions)
expect((await app1.diskStorageService.getAllRawPayloads()).length).length.to.equal(BASE_ITEM_COUNT + 1)
expect((await app2.diskStorageService.getAllRawPayloads()).length).length.to.equal(BASE_ITEM_COUNT)
await Factory.createMappedNote(app2)
await app2.syncService.sync(syncOptions)
expect((await app1.diskStorageService.getAllRawPayloads()).length).length.to.equal(BASE_ITEM_COUNT + 1)
expect((await app2.diskStorageService.getAllRawPayloads()).length).length.to.equal(BASE_ITEM_COUNT + 1)
await Factory.safeDeinit(app1)
await Factory.safeDeinit(app2)
})
it('deinit application while storage persisting should be handled gracefully', async () => {
/** This test will always succeed but should be observed for console exceptions */
const app = await Factory.createAndInitializeApplication('app')
/** Don't await */
app.diskStorageService.persistValuesToDisk()
await app.prepareForDeinit()
await Factory.safeDeinit(app)
})
it('changing default host should not affect already signed in accounts', async () => {
/** This test will always succeed but should be observed for console exceptions */
const app = await Factory.createAndInitializeApplication(
'app',
Environment.Web,
Platform.MacWeb,
Factory.getDefaultHost(),
)
await Factory.registerUserToApplication({
application: app,
email: UuidGenerator.GenerateUuid(),
password: 'password',
})
await app.prepareForDeinit()
await Factory.safeDeinit(app)
/** Recreate app with different host */
const recreatedApp = await Factory.createAndInitializeApplication(
'app',
Environment.Web,
Platform.MacWeb,
'http://nonsense.host',
)
expect(recreatedApp.getHost()).to.not.equal('http://nonsense.host')
expect(recreatedApp.getHost()).to.equal(Factory.getDefaultHost())
})
it('signing out application should delete snjs_version', async () => {
const identifier = 'app'
const app = await Factory.createAndInitializeApplication(identifier)
expect(localStorage.getItem(`${identifier}-snjs_version`)).to.be.ok
await app.user.signOut()
expect(localStorage.getItem(`${identifier}-snjs_version`)).to.not.be.ok
})
it('locking application while critical func in progress should wait up to a limit', async () => {
/** This test will always succeed but should be observed for console exceptions */
const app = await Factory.createAndInitializeApplication('app')
/** Don't await */
const MaximumWaitTime = 0.5
app.diskStorageService.executeCriticalFunction(async () => {
/** If we sleep less than the maximum, locking should occur safely.
* If we sleep more than the maximum, locking should occur with exception on
* app deinit. */
await Factory.sleep(MaximumWaitTime - 0.05)
/** Access any deviceInterface function */
app.diskStorageService.deviceInterface.getAllRawDatabasePayloads(app.identifier)
})
await app.lock()
})
describe('signOut()', () => {
let testNote1
let confirmAlert
let deinit
let testSNApp
const signOutConfirmMessage = (numberOfItems) => {
const singular = numberOfItems === 1
return (
`There ${singular ? 'is' : 'are'} ${numberOfItems} ${singular ? 'item' : 'items'} with unsynced changes. ` +
'If you sign out, these changes will be lost forever. Are you sure you want to sign out?'
)
}
beforeEach(async () => {
testSNApp = await Factory.createAndInitializeApplication('test-application')
testNote1 = await Factory.createMappedNote(testSNApp, 'Note 1', 'This is a test note!', false)
confirmAlert = sinon.spy(testSNApp.alertService, 'confirm')
deinit = sinon.spy(testSNApp, 'deinit')
})
it('shows confirmation dialog when there are unsaved changes', async () => {
await testSNApp.itemManager.setItemDirty(testNote1)
await testSNApp.user.signOut()
const expectedConfirmMessage = signOutConfirmMessage(1)
expect(confirmAlert.callCount).to.equal(1)
expect(confirmAlert.calledWith(expectedConfirmMessage)).to.be.ok
expect(deinit.callCount).to.equal(1)
expect(deinit.calledWith(DeinitMode.Soft, DeinitSource.SignOut)).to.be.ok
})
it('does not show confirmation dialog when there are no unsaved changes', async () => {
await testSNApp.user.signOut()
expect(confirmAlert.callCount).to.equal(0)
expect(deinit.callCount).to.equal(1)
expect(deinit.calledWith(DeinitMode.Soft, DeinitSource.SignOut)).to.be.ok
})
it('does not show confirmation dialog when there are unsaved changes and the "force" option is set to true', async () => {
await testSNApp.itemManager.setItemDirty(testNote1)
await testSNApp.user.signOut(true)
expect(confirmAlert.callCount).to.equal(0)
expect(deinit.callCount).to.equal(1)
expect(deinit.calledWith(DeinitMode.Soft, DeinitSource.SignOut)).to.be.ok
})
it('cancels sign out if confirmation dialog is rejected', async () => {
confirmAlert.restore()
confirmAlert = sinon.stub(testSNApp.alertService, 'confirm').callsFake((_message) => false)
await testSNApp.itemManager.setItemDirty(testNote1)
await testSNApp.user.signOut()
const expectedConfirmMessage = signOutConfirmMessage(1)
expect(confirmAlert.callCount).to.equal(1)
expect(confirmAlert.calledWith(expectedConfirmMessage)).to.be.ok
expect(deinit.callCount).to.equal(0)
})
})
})

View File

@@ -0,0 +1,288 @@
@charset "utf-8";
body {
margin: 0;
background-color: rgb(160, 160, 160);
}
#mocha {
font: 20px/1.5 'Helvetica Neue', Helvetica, Arial, sans-serif;
margin: 60px 50px;
}
#mocha ul,
#mocha li {
margin: 0;
padding: 0;
}
#mocha ul {
list-style: none;
}
#mocha h1,
#mocha h2 {
margin: 0;
}
#mocha h1 {
margin-top: 15px;
font-size: 1em;
font-weight: 200;
}
#mocha h1 a {
text-decoration: none;
color: inherit;
}
#mocha h1 a:hover {
text-decoration: underline;
}
#mocha .suite .suite h1 {
margin-top: 0;
font-size: 0.8em;
}
#mocha .hidden {
display: none;
}
#mocha h2 {
font-size: 12px;
font-weight: normal;
cursor: pointer;
}
#mocha .suite {
margin-left: 15px;
}
#mocha .test {
margin-left: 15px;
overflow: hidden;
}
#mocha .test.pending:hover h2::after {
content: '(pending)';
font-family: arial, sans-serif;
}
#mocha .test.pass.medium .duration {
background: #c09853;
}
#mocha .test.pass.slow .duration {
background: #b94a48;
}
#mocha .test.pass::before {
content: '✓';
font-size: 12px;
display: block;
float: left;
margin-right: 5px;
color: #00d6b2;
}
#mocha .test.pass .duration {
font-size: 9px;
margin-left: 5px;
padding: 2px 5px;
color: #fff;
-webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.2);
-moz-box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.2);
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.2);
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
-ms-border-radius: 5px;
-o-border-radius: 5px;
border-radius: 5px;
}
#mocha .test.pass.fast .duration {
display: none;
}
#mocha .test.pending {
color: #f0ec00;
}
#mocha .test.pending::before {
content: '◦';
color: #f0ec00;
}
#mocha .test.fail {
color: #c00;
}
#mocha .test.fail pre {
color: black;
}
#mocha .test.fail::before {
content: '✖';
font-size: 12px;
display: block;
float: left;
margin-right: 5px;
color: #c00;
}
#mocha .test pre.error {
color: #c00;
max-height: 300px;
overflow: auto;
}
/**
* (1): approximate for browsers not supporting calc
* (2): 42 = 2*15 + 2*10 + 2*1 (padding + margin + border)
* ^^ seriously
*/
#mocha .test pre {
display: block;
float: left;
clear: left;
font: 12px/1.5 monaco, monospace;
margin: 5px;
padding: 15px;
border: 1px solid #eee;
max-width: 85%;
/*(1)*/
max-width: calc(100% - 42px);
/*(2)*/
word-wrap: break-word;
border-bottom-color: #ddd;
-webkit-border-radius: 3px;
-webkit-box-shadow: 0 1px 3px #eee;
-moz-border-radius: 3px;
-moz-box-shadow: 0 1px 3px #eee;
border-radius: 3px;
}
#mocha .test h2 {
position: relative;
}
#mocha .test a.replay {
position: absolute;
top: 3px;
right: 0;
text-decoration: none;
vertical-align: middle;
display: block;
width: 15px;
height: 15px;
line-height: 15px;
text-align: center;
background: #eee;
font-size: 15px;
-moz-border-radius: 15px;
border-radius: 15px;
-webkit-transition: opacity 200ms;
-moz-transition: opacity 200ms;
transition: opacity 200ms;
opacity: 0.3;
color: #888;
}
#mocha .test:hover a.replay {
opacity: 1;
}
#mocha-report.pass .test.fail {
display: none;
}
#mocha-report.fail .test.pass {
display: none;
}
#mocha-report.pending .test.pass,
#mocha-report.pending .test.fail {
display: none;
}
#mocha-report.pending .test.pass.pending {
display: block;
}
#mocha-error {
color: #c00;
font-size: 1.5em;
font-weight: 100;
letter-spacing: 1px;
}
#mocha-stats {
position: fixed;
top: 15px;
right: 10px;
font-size: 12px;
margin: 0;
color: black;
z-index: 1;
}
#mocha-stats .progress {
float: right;
padding-top: 0;
}
#mocha-stats em {
color: #ddd;
}
#mocha-stats a {
text-decoration: none;
color: inherit;
}
#mocha-stats a:hover {
border-bottom: 1px solid #eee;
}
#mocha-stats li {
display: inline-block;
margin: 0 5px;
list-style: none;
padding-top: 11px;
}
#mocha-stats canvas {
width: 40px;
height: 40px;
}
#mocha code .comment {
color: #ddd;
}
#mocha code .init {
color: #2f6fad;
}
#mocha code .string {
color: #5890ad;
}
#mocha code .keyword {
color: #8a6343;
}
#mocha code .number {
color: #2f6fad;
}
@media screen and (max-device-width: 480px) {
#mocha {
margin: 60px 0px;
}
#mocha #stats {
position: absolute;
}
}

View File

@@ -0,0 +1,4 @@
Apple throws at users, without going back a huge marketing challenge. Interact said they didnt like a real pencil and I believe. Files brand is that Twitter for no other updates the idea is. Screen old pencil and that comes time to adapt to all too often,. Writes Illustrator, Photoshop, and vision to show more like a user allows users. Example next version of tools fighting to combine tablets and UIs separately. It, survey on the drawing board it makes is a brand design software. Interested I address the individual who recently signed with Adobe as Vinh writes. Connoted email but this meant that Google has had the individual. A to the 6 meanwhile, the way to all too loud. Senses that's a cars power, the concept of multi-sensory design to us make. A step of tools fighting to brainstorm solutions for years, things first, software still. Didn't track your progress in the brand experience but. Product of extra effort to design software still prefer. Using the modern designers workflow, according to worry about it mean by how. Have each app seemingly once a shift in a shift. Long and cooking odors from 35-inches the product or not interested in. Brand in the noise they interact to communicate a lot of having two. This lot of having said that, it in Slack,. Of of conditioning that's not a new app seemingly once.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,111 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('auth fringe cases', () => {
const BASE_ITEM_COUNT = ['default items key', 'user prefs'].length
const createContext = async () => {
const application = await Factory.createInitAppWithFakeCrypto()
return {
expectedItemCount: BASE_ITEM_COUNT,
application: application,
email: UuidGenerator.GenerateUuid(),
password: UuidGenerator.GenerateUuid(),
deinit: async () => {
await Factory.safeDeinit(application)
},
}
}
beforeEach(async function () {
localStorage.clear()
})
afterEach(async function () {
localStorage.clear()
})
const clearApplicationLocalStorage = function () {
const keys = Object.keys(localStorage)
for (const key of keys) {
if (!key.toLowerCase().includes('item')) {
localStorage.removeItem(key)
}
}
}
const awaitSync = true
describe('localStorage improperly cleared with 1 item', function () {
it('item should be errored', async function () {
const context = await createContext()
await context.application.register(context.email, context.password)
const note = await Factory.createSyncedNote(context.application)
clearApplicationLocalStorage()
console.warn("Expecting errors 'Unable to find operator for version undefined'")
const restartedApplication = await Factory.restartApplication(context.application)
const refreshedNote = restartedApplication.payloadManager.findOne(note.uuid)
expect(refreshedNote.errorDecrypting).to.equal(true)
await Factory.safeDeinit(restartedApplication)
})
it('signing in again should decrypt item', async function () {
const context = await createContext()
await context.application.register(context.email, context.password)
const note = await Factory.createSyncedNote(context.application)
clearApplicationLocalStorage()
const restartedApplication = await Factory.restartApplication(context.application)
console.warn(
"Expecting errors 'No associated key found for item encrypted with latest protocol version.'",
"and 'Unable to find operator for version undefined'",
)
await restartedApplication.signIn(context.email, context.password, undefined, undefined, undefined, awaitSync)
const refreshedNote = restartedApplication.itemManager.findItem(note.uuid)
expect(isDecryptedItem(refreshedNote)).to.equal(true)
expect(restartedApplication.itemManager.getDisplayableNotes().length).to.equal(1)
await Factory.safeDeinit(restartedApplication)
}).timeout(10000)
})
describe('having offline item matching remote item uuid', function () {
it('offline item should not overwrite recently updated server item and conflict should be created', async function () {
const context = await createContext()
await context.application.register(context.email, context.password)
const staleText = 'stale text'
const firstVersionOfNote = await Factory.createSyncedNote(context.application, undefined, staleText)
const serverText = 'server text'
await context.application.mutator.changeAndSaveItem(firstVersionOfNote, (mutator) => {
mutator.text = serverText
})
const newApplication = await Factory.signOutApplicationAndReturnNew(context.application)
/** Create same note but now offline */
await newApplication.itemManager.emitItemFromPayload(firstVersionOfNote.payload)
/** Sign in and merge local data */
await newApplication.signIn(context.email, context.password, undefined, undefined, true, true)
expect(newApplication.itemManager.getDisplayableNotes().length).to.equal(2)
expect(newApplication.itemManager.getDisplayableNotes().find((n) => n.uuid === firstVersionOfNote.uuid).text).to.equal(staleText)
const conflictedCopy = newApplication.itemManager.getDisplayableNotes().find((n) => n.uuid !== firstVersionOfNote.uuid)
expect(conflictedCopy.text).to.equal(serverText)
expect(conflictedCopy.duplicate_of).to.equal(firstVersionOfNote.uuid)
await Factory.safeDeinit(newApplication)
}).timeout(10000)
})
})

View File

@@ -0,0 +1,519 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('basic auth', function () {
this.timeout(Factory.TenSecondTimeout)
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
beforeEach(async function () {
localStorage.clear()
this.expectedItemCount = BASE_ITEM_COUNT
this.application = await Factory.createInitAppWithFakeCrypto()
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
localStorage.clear()
})
it('successfully register new account', async function () {
const response = await this.application.register(this.email, this.password)
expect(response).to.be.ok
expect(await this.application.protocolService.getRootKey()).to.be.ok
})
it('fails register new account with short password', async function () {
const password = '123456'
let error = null
try {
await this.application.register(this.email, password)
} catch(caughtError) {
error = caughtError
}
expect(error.message).to.equal('Your password must be at least 8 characters in length. '
+ 'For your security, please choose a longer password or, ideally, a passphrase, and try again.')
expect(await this.application.protocolService.getRootKey()).to.not.be.ok
})
it('successfully signs out of account', async function () {
await this.application.register(this.email, this.password)
expect(await this.application.protocolService.getRootKey()).to.be.ok
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
expect(await this.application.protocolService.getRootKey()).to.not.be.ok
expect(this.application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyNone)
const rawPayloads = await this.application.diskStorageService.getAllRawPayloads()
expect(rawPayloads.length).to.equal(BASE_ITEM_COUNT)
})
it('successfully signs in to registered account', async function () {
await this.application.register(this.email, this.password)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
const response = await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true)
expect(response).to.be.ok
expect(response.error).to.not.be.ok
expect(await this.application.protocolService.getRootKey()).to.be.ok
}).timeout(20000)
it('cannot sign while already signed in', async function () {
await this.application.register(this.email, this.password)
await Factory.createSyncedNote(this.application)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
const response = await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true)
expect(response).to.be.ok
expect(response.error).to.not.be.ok
expect(await this.application.protocolService.getRootKey()).to.be.ok
let error
try {
await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true)
} catch (e) {
error = e
}
expect(error).to.be.ok
}).timeout(20000)
it('cannot register while already signed in', async function () {
await this.application.register(this.email, this.password)
let error
try {
await this.application.register(this.email, this.password)
} catch (e) {
error = e
}
expect(error).to.be.ok
expect(await this.application.protocolService.getRootKey()).to.be.ok
}).timeout(20000)
it('cannot perform two sign-ins at the same time', async function () {
await this.application.register(this.email, this.password)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
await Promise.all([
(async () => {
const response = await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true)
expect(response).to.be.ok
expect(response.error).to.not.be.ok
expect(await this.application.protocolService.getRootKey()).to.be.ok
})(),
(async () => {
/** Make sure the first function runs first */
await new Promise((resolve) => setTimeout(resolve))
/** Try to sign in while the first request is going */
let error
try {
await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true)
} catch (e) {
error = e
}
expect(error).to.be.ok
})(),
])
}).timeout(20000)
it('cannot perform two register operations at the same time', async function () {
await Promise.all([
(async () => {
const response = await this.application.register(this.email, this.password)
expect(response).to.be.ok
expect(response.error).to.not.be.ok
expect(await this.application.protocolService.getRootKey()).to.be.ok
})(),
(async () => {
/** Make sure the first function runs first */
await new Promise((resolve) => setTimeout(resolve))
/** Try to register in while the first request is going */
let error
try {
await this.application.register(this.email, this.password)
} catch (e) {
error = e
}
expect(error).to.be.ok
})(),
])
}).timeout(20000)
it('successfuly signs in after failing once', async function () {
await this.application.register(this.email, this.password)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
let response = await this.application.signIn(this.email, 'wrong password', undefined, undefined, undefined, true)
expect(response).to.have.property('status', 401)
expect(response.error).to.be.ok
response = await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true)
expect(response.status).to.equal(200)
expect(response).to.not.haveOwnProperty('error')
}).timeout(20000)
it('server retrieved key params should use our client inputted value for identifier', async function () {
/**
* We should ensure that when we retrieve key params from the server, in order to generate a root
* key server password for login, that the identifier used in the key params is the client side entered
* value, and not the value returned from the server.
*
* Apart from wanting to minimze trust from the server, we also want to ensure that if
* we register with an uppercase identifier, and request key params with the lowercase equivalent,
* that even though the server performs a case-insensitive search on email fields, we correct
* for this action locally.
*/
const rand = `${Math.random()}`
const uppercase = `FOO@BAR.COM${rand}`
const lowercase = `foo@bar.com${rand}`
/**
* Registering with an uppercase email should still allow us to sign in
* with lowercase email
*/
await this.application.register(uppercase, this.password)
const response = await this.application.sessionManager.retrieveKeyParams(lowercase)
const keyParams = response.keyParams
expect(keyParams.identifier).to.equal(lowercase)
expect(keyParams.identifier).to.not.equal(uppercase)
}).timeout(20000)
it('can sign into account regardless of email case', async function () {
const rand = `${Math.random()}`
const uppercase = `FOO@BAR.COM${rand}`
const lowercase = `foo@bar.com${rand}`
/**
* Registering with a lowercase email should allow us to sign in
* with an uppercase email
*/
await this.application.register(lowercase, this.password)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
const response = await this.application.signIn(uppercase, this.password, undefined, undefined, undefined, true)
expect(response).to.be.ok
expect(response.error).to.not.be.ok
expect(await this.application.protocolService.getRootKey()).to.be.ok
}).timeout(20000)
it('can sign into account regardless of whitespace', async function () {
const rand = `${Math.random()}`
const withspace = `FOO@BAR.COM${rand} `
const nospace = `foo@bar.com${rand}`
/**
* Registering with a lowercase email should allow us to sign in
* with an uppercase email
*/
await this.application.register(nospace, this.password)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
const response = await this.application.signIn(withspace, this.password, undefined, undefined, undefined, true)
expect(response).to.be.ok
expect(response.error).to.not.be.ok
expect(await this.application.protocolService.getRootKey()).to.be.ok
}).timeout(20000)
it('fails login with wrong password', async function () {
await this.application.register(this.email, this.password)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
const response = await this.application.signIn(this.email, 'wrongpassword', undefined, undefined, undefined, true)
expect(response).to.be.ok
expect(response.error).to.be.ok
expect(await this.application.protocolService.getRootKey()).to.not.be.ok
}).timeout(20000)
it('fails to change to short password', async function () {
await this.application.register(this.email, this.password)
const newPassword = '123456'
const response = await this.application.changePassword(this.password, newPassword)
expect(response.error).to.be.ok
}).timeout(20000)
it('fails to change password when current password is incorrect', async function () {
await this.application.register(this.email, this.password)
const response = await this.application.changePassword('Invalid password', 'New password')
expect(response.error).to.be.ok
/** Ensure we can still log in */
this.application = await Factory.signOutAndBackIn(this.application, this.email, this.password)
}).timeout(20000)
it('registering for new account and completing first after download sync should not put us out of sync', async function () {
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
let outOfSync = true
let didCompletePostDownloadFirstSync = false
let didCompleteDownloadFirstSync = false
this.application.syncService.addEventObserver((eventName) => {
if (eventName === SyncEvent.DownloadFirstSyncCompleted) {
didCompleteDownloadFirstSync = true
}
if (!didCompleteDownloadFirstSync) {
return
}
if (!didCompletePostDownloadFirstSync && eventName === SyncEvent.SingleRoundTripSyncCompleted) {
didCompletePostDownloadFirstSync = true
/** Should be in sync */
outOfSync = this.application.syncService.isOutOfSync()
}
})
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
expect(didCompleteDownloadFirstSync).to.equal(true)
expect(didCompletePostDownloadFirstSync).to.equal(true)
expect(outOfSync).to.equal(false)
})
async function changePassword() {
await this.application.register(this.email, this.password)
const noteCount = 10
await Factory.createManyMappedNotes(this.application, noteCount)
this.expectedItemCount += noteCount
await this.application.syncService.sync(syncOptions)
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
const newPassword = 'newpassword'
const response = await this.application.changePassword(this.password, newPassword)
/** New items key */
this.expectedItemCount++
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
expect(response.error).to.not.be.ok
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
expect(this.application.payloadManager.invalidPayloads.length).to.equal(0)
await this.application.syncService.markAllItemsAsNeedingSyncAndPersist()
await this.application.syncService.sync(syncOptions)
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
const note = this.application.itemManager.getDisplayableNotes()[0]
/**
* Create conflict for a note. First modify the item without saving so that
* our local contents digress from the server's
*/
await this.application.mutator.changeItem(note, (mutator) => {
mutator.title = `${Math.random()}`
})
await Factory.changePayloadTimeStampAndSync(
this.application,
note.payload,
Factory.dateToMicroseconds(Factory.yesterday()),
{
title: `${Math.random()}`,
},
syncOptions,
)
this.expectedItemCount++
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
/** Should login with new password */
const signinResponse = await this.application.signIn(this.email, newPassword, undefined, undefined, undefined, true)
expect(signinResponse).to.be.ok
expect(signinResponse.error).to.not.be.ok
expect(await this.application.protocolService.getRootKey()).to.be.ok
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
expect(this.application.payloadManager.invalidPayloads.length).to.equal(0)
}
it('successfully changes password', changePassword).timeout(40000)
it.skip('successfully changes password when passcode is set', async function () {
const passcode = 'passcode'
const promptValueReply = (prompts) => {
const values = []
for (const prompt of prompts) {
if (prompt.validation === ChallengeValidation.LocalPasscode) {
values.push(CreateChallengeValue(prompt, passcode))
} else {
values.push(CreateChallengeValue(prompt, this.password))
}
}
return values
}
this.application.setLaunchCallback({
receiveChallenge: (challenge) => {
this.application.addChallengeObserver(challenge, {
onInvalidValue: (value) => {
const values = promptValueReply([value.prompt])
this.application.submitValuesForChallenge(challenge, values)
numPasscodeAttempts++
},
})
const initialValues = promptValueReply(challenge.prompts)
this.application.submitValuesForChallenge(challenge, initialValues)
},
})
await this.application.setPasscode(passcode)
await changePassword.bind(this)()
}).timeout(20000)
it('changes password many times', async function () {
await this.application.register(this.email, this.password)
const noteCount = 10
await Factory.createManyMappedNotes(this.application, noteCount)
this.expectedItemCount += noteCount
await this.application.syncService.sync(syncOptions)
const numTimesToChangePw = 3
let newPassword = Factory.randomString()
let currentPassword = this.password
for (let i = 0; i < numTimesToChangePw; i++) {
await this.application.changePassword(currentPassword, newPassword)
/** New items key */
this.expectedItemCount++
currentPassword = newPassword
newPassword = Factory.randomString()
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
expect(this.application.payloadManager.invalidPayloads.length).to.equal(0)
await this.application.syncService.markAllItemsAsNeedingSyncAndPersist()
await this.application.syncService.sync(syncOptions)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
expect(this.application.itemManager.items.length).to.equal(BASE_ITEM_COUNT)
expect(this.application.payloadManager.invalidPayloads.length).to.equal(0)
/** Should login with new password */
const signinResponse = await this.application.signIn(
this.email,
currentPassword,
undefined,
undefined,
undefined,
true,
)
expect(signinResponse).to.be.ok
expect(signinResponse.error).to.not.be.ok
expect(await this.application.protocolService.getRootKey()).to.be.ok
}
}).timeout(80000)
it('signing in with a clean email string should only try once', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
const performSignIn = sinon.spy(this.application.sessionManager, 'performSignIn')
await this.application.signIn(this.email, 'wrong password', undefined, undefined, undefined, true)
expect(performSignIn.callCount).to.equal(1)
})
describe('add passcode', function () {
it('should set passcode successfully', async function () {
const passcode = 'passcode'
const result = await this.application.addPasscode(passcode)
expect(result).to.be.true
})
it('should fail when attempting to set 0 character passcode', async function () {
const passcode = ''
const result = await this.application.addPasscode(passcode)
expect(result).to.be.false
})
})
describe('change passcode', function () {
it('should change passcode successfully', async function () {
const passcode = 'passcode'
const newPasscode = 'newPasscode'
await this.application.addPasscode(passcode)
Factory.handlePasswordChallenges(this.application, passcode)
const result = await this.application.changePasscode(newPasscode)
expect(result).to.be.true
}).timeout(Factory.TenSecondTimeout)
it('should fail when attempting to change to a 0 character passcode', async function () {
const passcode = 'passcode'
const newPasscode = ''
await this.application.addPasscode(passcode)
Factory.handlePasswordChallenges(this.application, passcode)
const result = await this.application.changePasscode(newPasscode)
expect(result).to.be.false
}).timeout(Factory.TenSecondTimeout)
})
describe.skip('account deletion', function () {
it('should delete account', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
Factory.handlePasswordChallenges(this.application, this.password)
const _response = await this.application.user.deleteAccount()
}).timeout(Factory.TenSecondTimeout)
it('should prompt for account password when deleting account', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
Factory.handlePasswordChallenges(this.application, this.password)
const _response = await this.application.deleteAccount()
sinon.spy(snApp.challengeService, 'sendChallenge')
const spyCall = snApp.challengeService.sendChallenge.getCall(0)
const challenge = spyCall.firstArg
expect(challenge.prompts).to.have.lengthOf(2)
expect(challenge.prompts[0].validation).to.equal(ChallengeValidation.AccountPassword)
// ...
}).timeout(Factory.TenSecondTimeout)
it('deleting account should sign out current user', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
Factory.handlePasswordChallenges(this.application, this.password)
const _response = await this.application.deleteAccount()
expect(application.hasAccount()).to.be.false
}).timeout(Factory.TenSecondTimeout)
})
})

View File

@@ -0,0 +1,221 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('backups', function () {
before(function () {
localStorage.clear()
})
after(function () {
localStorage.clear()
})
beforeEach(async function () {
this.application = await Factory.createInitAppWithFakeCrypto()
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
this.application = null
})
const BASE_ITEM_COUNT_ENCRYPTED = ['ItemsKey', 'UserPreferences'].length
const BASE_ITEM_COUNT_DECRYPTED = ['UserPreferences'].length
it('backup file should have a version number', async function () {
let data = await this.application.createDecryptedBackupFile()
expect(data.version).to.equal(this.application.protocolService.getLatestVersion())
await this.application.addPasscode('passcode')
data = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups()
expect(data.version).to.equal(this.application.protocolService.getLatestVersion())
})
it('no passcode + no account backup file should have correct number of items', async function () {
await Promise.all([Factory.createSyncedNote(this.application), Factory.createSyncedNote(this.application)])
const data = await this.application.createDecryptedBackupFile()
expect(data.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2)
})
it('passcode + no account backup file should have correct number of items', async function () {
const passcode = 'passcode'
await this.application.addPasscode(passcode)
await Promise.all([Factory.createSyncedNote(this.application), Factory.createSyncedNote(this.application)])
// Encrypted backup without authorization
const encryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups()
expect(encryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2)
// Encrypted backup with authorization
Factory.handlePasswordChallenges(this.application, passcode)
const authorizedEncryptedData = await this.application.createEncryptedBackupFile()
expect(authorizedEncryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2)
})
it('no passcode + account backup file should have correct number of items', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
await Promise.all([Factory.createSyncedNote(this.application), Factory.createSyncedNote(this.application)])
// Encrypted backup without authorization
const encryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups()
expect(encryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2)
Factory.handlePasswordChallenges(this.application, this.password)
// Decrypted backup
const decryptedData = await this.application.createDecryptedBackupFile()
expect(decryptedData.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2)
// Encrypted backup with authorization
const authorizedEncryptedData = await this.application.createEncryptedBackupFile()
expect(authorizedEncryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2)
})
it('passcode + account backup file should have correct number of items', async function () {
this.timeout(10000)
const passcode = 'passcode'
await this.application.register(this.email, this.password)
Factory.handlePasswordChallenges(this.application, this.password)
await this.application.addPasscode(passcode)
await Promise.all([Factory.createSyncedNote(this.application), Factory.createSyncedNote(this.application)])
// Encrypted backup without authorization
const encryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups()
expect(encryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2)
Factory.handlePasswordChallenges(this.application, passcode)
// Decrypted backup
const decryptedData = await this.application.createDecryptedBackupFile()
expect(decryptedData.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2)
// Encrypted backup with authorization
const authorizedEncryptedData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups()
expect(authorizedEncryptedData.items.length).to.equal(BASE_ITEM_COUNT_ENCRYPTED + 2)
})
it('backup file item should have correct fields', async function () {
await Factory.createSyncedNote(this.application)
let backupData = await this.application.createDecryptedBackupFile()
let rawItem = backupData.items.find((i) => i.content_type === ContentType.Note)
expect(rawItem.fields).to.not.be.ok
expect(rawItem.source).to.not.be.ok
expect(rawItem.dirtyIndex).to.not.be.ok
expect(rawItem.format).to.not.be.ok
expect(rawItem.uuid).to.be.ok
expect(rawItem.content_type).to.be.ok
expect(rawItem.content).to.be.ok
expect(rawItem.created_at).to.be.ok
expect(rawItem.updated_at).to.be.ok
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
backupData = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups()
rawItem = backupData.items.find((i) => i.content_type === ContentType.Note)
expect(rawItem.fields).to.not.be.ok
expect(rawItem.source).to.not.be.ok
expect(rawItem.dirtyIndex).to.not.be.ok
expect(rawItem.format).to.not.be.ok
expect(rawItem.uuid).to.be.ok
expect(rawItem.content_type).to.be.ok
expect(rawItem.content).to.be.ok
expect(rawItem.created_at).to.be.ok
expect(rawItem.updated_at).to.be.ok
})
it('downloading backup if item is error decrypting should succeed', async function () {
await Factory.createSyncedNote(this.application)
const note = await Factory.createSyncedNote(this.application)
const encrypted = await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [note.payload],
},
})
const errored = encrypted.copy({
errorDecrypting: true,
})
await this.application.itemManager.emitItemFromPayload(errored)
const erroredItem = this.application.itemManager.findAnyItem(errored.uuid)
expect(erroredItem.errorDecrypting).to.equal(true)
const backupData = await this.application.createDecryptedBackupFile()
expect(backupData.items.length).to.equal(BASE_ITEM_COUNT_DECRYPTED + 2)
})
it('decrypted backup file should not have keyParams', async function () {
const backup = await this.application.createDecryptedBackupFile()
expect(backup).to.not.haveOwnProperty('keyParams')
})
it('decrypted backup file with account should not have keyParams', async function () {
const application = await Factory.createInitAppWithFakeCrypto()
const password = UuidGenerator.GenerateUuid()
await Factory.registerUserToApplication({
application: application,
email: UuidGenerator.GenerateUuid(),
password: password,
})
Factory.handlePasswordChallenges(application, password)
const backup = await application.createDecryptedBackupFile()
expect(backup).to.not.haveOwnProperty('keyParams')
await Factory.safeDeinit(application)
})
it('encrypted backup file should have keyParams', async function () {
await this.application.addPasscode('passcode')
const backup = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups()
expect(backup).to.haveOwnProperty('keyParams')
})
it('decrypted backup file should not have itemsKeys', async function () {
const backup = await this.application.createDecryptedBackupFile()
expect(backup.items.some((item) => item.content_type === ContentType.ItemsKey)).to.be.false
})
it('encrypted backup file should have itemsKeys', async function () {
await this.application.addPasscode('passcode')
const backup = await this.application.createEncryptedBackupFileForAutomatedDesktopBackups()
expect(backup.items.some((item) => item.content_type === ContentType.ItemsKey)).to.be.true
})
it('backup file with no account and no passcode should be decrypted', async function () {
const note = await Factory.createSyncedNote(this.application)
const backup = await this.application.createDecryptedBackupFile()
expect(backup).to.not.haveOwnProperty('keyParams')
expect(backup.items.some((item) => item.content_type === ContentType.ItemsKey)).to.be.false
expect(backup.items.find((item) => item.content_type === ContentType.Note).uuid).to.equal(note.uuid)
let error
try {
await this.application.createEncryptedBackupFileForAutomatedDesktopBackups()
} catch (e) {
error = e
}
expect(error).to.be.ok
})
})

View File

@@ -0,0 +1,84 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
import { createRelatedNoteTagPairPayload } from './lib/Items.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('payload collections', () => {
before(async () => {
localStorage.clear()
})
after(async () => {
localStorage.clear()
})
const copyNote = (note, timestamp, changeUuid) => {
return new SNNote(
note.payload.copy({
uuid: changeUuid ? Factory.generateUuidish() : note.payload.uuid,
created_at: timestamp ? new Date(timestamp) : new Date(),
}),
)
}
it('find', async () => {
const payload = Factory.createNotePayload()
const collection = ImmutablePayloadCollection.WithPayloads([payload])
expect(collection.find(payload.uuid)).to.be.ok
})
it('references', async () => {
const payloads = createRelatedNoteTagPairPayload()
const notePayload = payloads[0]
const tagPayload = payloads[1]
const collection = ImmutablePayloadCollection.WithPayloads([notePayload, tagPayload])
const referencing = collection.elementsReferencingElement(notePayload)
expect(referencing.length).to.equal(1)
})
it('references by content type', async () => {
const [notePayload1, tagPayload1] = createRelatedNoteTagPairPayload()
const collection = ImmutablePayloadCollection.WithPayloads([notePayload1, tagPayload1])
const referencingTags = collection.elementsReferencingElement(notePayload1, ContentType.Tag)
expect(referencingTags.length).to.equal(1)
expect(referencingTags[0].uuid).to.equal(tagPayload1.uuid)
const referencingNotes = collection.elementsReferencingElement(notePayload1, ContentType.Note)
expect(referencingNotes.length).to.equal(0)
})
it('conflict map', async () => {
const payload = Factory.createNotePayload()
const collection = new PayloadCollection()
collection.set([payload])
const conflict = payload.copy({
content: {
conflict_of: payload.uuid,
...payload.content,
},
})
collection.set([conflict])
expect(collection.conflictsOf(payload.uuid)).to.eql([conflict])
const manualResults = collection.all().find((p) => {
return p.content.conflict_of === payload.uuid
})
expect(collection.conflictsOf(payload.uuid)).to.eql([manualResults])
})
it('setting same element twice should not yield duplicates', async () => {
const collection = new PayloadCollection()
const payload = Factory.createNotePayload()
const copy = payload.copy()
collection.set([payload, copy])
collection.set([payload])
collection.set([payload, copy])
const sorted = collection.all(ContentType.Note)
expect(sorted.length).to.equal(1)
})
})

View File

@@ -0,0 +1,164 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('device authentication', function () {
beforeEach(async function () {
localStorage.clear()
})
afterEach(async function () {
localStorage.clear()
})
it('handles application launch with passcode only', async function () {
const namespace = Factory.randomString()
const application = await Factory.createAndInitializeApplication(namespace)
const passcode = 'foobar'
const wrongPasscode = 'barfoo'
expect(await application.protectionService.createLaunchChallenge()).to.not.be.ok
await application.addPasscode(passcode)
expect(await application.hasPasscode()).to.equal(true)
expect(await application.protectionService.createLaunchChallenge()).to.be.ok
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly)
await Factory.safeDeinit(application)
/** Recreate application and initialize */
const tmpApplication = await Factory.createApplicationWithFakeCrypto(namespace)
let numPasscodeAttempts = 0
const promptValueReply = (prompts) => {
const values = []
for (const prompt of prompts) {
if (prompt.validation === ChallengeValidation.LocalPasscode) {
values.push(CreateChallengeValue(prompt, numPasscodeAttempts < 2 ? wrongPasscode : passcode))
}
}
return values
}
const receiveChallenge = async (challenge) => {
tmpApplication.addChallengeObserver(challenge, {
onInvalidValue: (value) => {
const values = promptValueReply([value.prompt])
tmpApplication.submitValuesForChallenge(challenge, values)
numPasscodeAttempts++
},
})
const initialValues = promptValueReply(challenge.prompts)
tmpApplication.submitValuesForChallenge(challenge, initialValues)
}
await tmpApplication.prepareForLaunch({ receiveChallenge })
expect(await tmpApplication.protocolService.getRootKey()).to.not.be.ok
await tmpApplication.launch(true)
expect(await tmpApplication.protocolService.getRootKey()).to.be.ok
expect(tmpApplication.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly)
await Factory.safeDeinit(tmpApplication)
}).timeout(10000)
it('handles application launch with passcode and biometrics', async function () {
const namespace = Factory.randomString()
const application = await Factory.createAndInitializeApplication(namespace)
const passcode = 'foobar'
const wrongPasscode = 'barfoo'
await application.addPasscode(passcode)
await application.protectionService.enableBiometrics()
expect(await application.hasPasscode()).to.equal(true)
expect((await application.protectionService.createLaunchChallenge()).prompts.length).to.equal(2)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly)
await Factory.safeDeinit(application)
/** Recreate application and initialize */
const tmpApplication = await Factory.createApplicationWithFakeCrypto(namespace)
let numPasscodeAttempts = 1
const promptValueReply = (prompts) => {
const values = []
for (const prompt of prompts) {
if (prompt.validation === ChallengeValidation.LocalPasscode) {
const response = { prompt, value: numPasscodeAttempts < 2 ? wrongPasscode : passcode }
values.push(response)
} else if (prompt.validation === ChallengeValidation.Biometric) {
values.push({ prompt, value: true })
}
}
return values
}
const receiveChallenge = async (challenge) => {
tmpApplication.addChallengeObserver(challenge, {
onInvalidValue: (value) => {
const values = promptValueReply([value.prompt])
tmpApplication.submitValuesForChallenge(challenge, values)
numPasscodeAttempts++
},
})
const initialValues = promptValueReply(challenge.prompts)
tmpApplication.submitValuesForChallenge(challenge, initialValues)
}
await tmpApplication.prepareForLaunch({ receiveChallenge })
expect(await tmpApplication.protocolService.getRootKey()).to.not.be.ok
expect((await tmpApplication.protectionService.createLaunchChallenge()).prompts.length).to.equal(2)
await tmpApplication.launch(true)
expect(await tmpApplication.protocolService.getRootKey()).to.be.ok
expect(tmpApplication.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly)
await Factory.safeDeinit(tmpApplication)
}).timeout(Factory.TwentySecondTimeout)
it('handles application launch with passcode and account', async function () {
const namespace = Factory.randomString()
const application = await Factory.createAndInitializeApplication(namespace)
const email = UuidGenerator.GenerateUuid()
const password = UuidGenerator.GenerateUuid()
await Factory.registerUserToApplication({
application: application,
email,
password,
})
const sampleStorageKey = 'foo'
const sampleStorageValue = 'bar'
await application.diskStorageService.setValue(sampleStorageKey, sampleStorageValue)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly)
const passcode = 'foobar'
Factory.handlePasswordChallenges(application, password)
await application.addPasscode(passcode)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper)
expect(await application.hasPasscode()).to.equal(true)
await Factory.safeDeinit(application)
const wrongPasscode = 'barfoo'
let numPasscodeAttempts = 1
/** Recreate application and initialize */
const tmpApplication = await Factory.createApplicationWithFakeCrypto(namespace)
const promptValueReply = (prompts) => {
const values = []
for (const prompt of prompts) {
if (prompt.validation === ChallengeValidation.LocalPasscode) {
values.push({ prompt, value: numPasscodeAttempts < 2 ? wrongPasscode : passcode })
}
}
return values
}
const receiveChallenge = async (challenge) => {
tmpApplication.addChallengeObserver(challenge, {
onInvalidValue: (value) => {
const values = promptValueReply([value.prompt])
tmpApplication.submitValuesForChallenge(challenge, values)
numPasscodeAttempts++
},
})
const initialValues = promptValueReply(challenge.prompts)
tmpApplication.submitValuesForChallenge(challenge, initialValues)
}
await tmpApplication.prepareForLaunch({
receiveChallenge: receiveChallenge,
})
expect(await tmpApplication.protocolService.getRootKey()).to.not.be.ok
await tmpApplication.launch(true)
expect(await tmpApplication.diskStorageService.getValue(sampleStorageKey)).to.equal(sampleStorageValue)
expect(await tmpApplication.protocolService.getRootKey()).to.be.ok
expect(tmpApplication.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper)
await Factory.safeDeinit(tmpApplication)
}).timeout(Factory.TwentySecondTimeout)
})

View File

@@ -0,0 +1,315 @@
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('features', () => {
let application
let email
let password
let midnightThemeFeature
let plusEditorFeature
let tagNestingFeature
let getUserFeatures
beforeEach(async function () {
application = await Factory.createInitAppWithFakeCrypto()
const now = new Date()
const tomorrow = now.setDate(now.getDate() + 1)
midnightThemeFeature = {
...GetFeatures().find((feature) => feature.identifier === FeatureIdentifier.MidnightTheme),
expires_at: tomorrow,
}
plusEditorFeature = {
...GetFeatures().find((feature) => feature.identifier === FeatureIdentifier.PlusEditor),
expires_at: tomorrow,
}
tagNestingFeature = {
...GetFeatures().find((feature) => feature.identifier === FeatureIdentifier.TagNesting),
expires_at: tomorrow,
}
sinon.spy(application.itemManager, 'createItem')
sinon.spy(application.itemManager, 'changeComponent')
sinon.spy(application.itemManager, 'setItemsToBeDeleted')
getUserFeatures = sinon.stub(application.apiService, 'getUserFeatures').callsFake(() => {
return Promise.resolve({
data: {
features: [midnightThemeFeature, plusEditorFeature, tagNestingFeature],
},
})
})
email = UuidGenerator.GenerateUuid()
password = UuidGenerator.GenerateUuid()
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
})
afterEach(async function () {
Factory.safeDeinit(application)
sinon.restore()
})
describe('new user roles received on api response meta', () => {
it('should save roles and features', async () => {
expect(application.featuresService.roles).to.have.lengthOf(1)
expect(application.featuresService.roles[0]).to.equal('CORE_USER')
expect(application.featuresService.features).to.have.lengthOf(3)
expect(application.featuresService.features[0]).to.containSubset(midnightThemeFeature)
expect(application.featuresService.features[1]).to.containSubset(plusEditorFeature)
const storedRoles = await application.getValue(StorageKey.UserRoles)
expect(storedRoles).to.have.lengthOf(1)
expect(storedRoles[0]).to.equal('CORE_USER')
const storedFeatures = await application.getValue(StorageKey.UserFeatures)
expect(storedFeatures).to.have.lengthOf(3)
expect(storedFeatures[0]).to.containSubset(midnightThemeFeature)
expect(storedFeatures[1]).to.containSubset(plusEditorFeature)
expect(storedFeatures[2]).to.containSubset(tagNestingFeature)
})
it('should fetch user features and create items for features with content type', async () => {
expect(application.apiService.getUserFeatures.callCount).to.equal(1)
expect(application.itemManager.createItem.callCount).to.equal(2)
const themeItems = application.items.getItems(ContentType.Theme)
const editorItems = application.items.getItems(ContentType.Component)
expect(themeItems).to.have.lengthOf(1)
expect(editorItems).to.have.lengthOf(1)
expect(themeItems[0].content).to.containSubset(
JSON.parse(
JSON.stringify({
name: midnightThemeFeature.name,
package_info: midnightThemeFeature,
valid_until: new Date(midnightThemeFeature.expires_at),
}),
),
)
expect(editorItems[0].content).to.containSubset(
JSON.parse(
JSON.stringify({
name: plusEditorFeature.name,
area: plusEditorFeature.area,
package_info: plusEditorFeature,
valid_until: new Date(midnightThemeFeature.expires_at),
}),
),
)
})
it('should update content for existing feature items', async () => {
// Wipe items from initial sync
await application.itemManager.removeAllItemsFromMemory()
// Wipe roles from initial sync
await application.featuresService.setRoles([])
// Create pre-existing item for theme without all the info
await application.itemManager.createItem(
ContentType.Theme,
FillItemContent({
package_info: {
identifier: FeatureIdentifier.MidnightTheme,
},
}),
)
// Call sync intentionally to get roles again in meta
await application.sync.sync()
// Timeout since we don't await for features update
await new Promise((resolve) => setTimeout(resolve, 1000))
expect(application.itemManager.changeComponent.callCount).to.equal(1)
const themeItems = application.items.getItems(ContentType.Theme)
expect(themeItems).to.have.lengthOf(1)
expect(themeItems[0].content).to.containSubset(
JSON.parse(
JSON.stringify({
package_info: midnightThemeFeature,
valid_until: new Date(midnightThemeFeature.expires_at),
}),
),
)
})
it('should delete theme item if feature has expired', async () => {
const now = new Date()
const yesterday = now.setDate(now.getDate() - 1)
getUserFeatures.restore()
sinon.stub(application.apiService, 'getUserFeatures').callsFake(() => {
return Promise.resolve({
data: {
features: [
{
...midnightThemeFeature,
expires_at: yesterday,
},
],
},
})
})
const themeItem = application.items.getItems(ContentType.Theme)[0]
// Wipe roles from initial sync
await application.featuresService.setRoles([])
// Call sync intentionally to get roles again in meta
await application.sync.sync()
// Timeout since we don't await for features update
await new Promise((resolve) => setTimeout(resolve, 1000))
expect(application.itemManager.setItemsToBeDeleted.calledWith([sinon.match({ uuid: themeItem.uuid })])).to.equal(
true,
)
const noTheme = application.items.getItems(ContentType.Theme)[0]
expect(noTheme).to.not.be.ok
})
})
it('should provide feature', async () => {
const feature = application.features.getUserFeature(FeatureIdentifier.PlusEditor)
expect(feature).to.containSubset(plusEditorFeature)
})
describe('extension repo items observer', () => {
it('should migrate to user setting when extension repo is added', async () => {
sinon.stub(application.apiService, 'isThirdPartyHostUsed').callsFake(() => {
return false
})
expect(await application.settings.getDoesSensitiveSettingExist(SettingName.ExtensionKey)).to.equal(false)
const extensionKey = UuidGenerator.GenerateUuid().split('-').join('')
const promise = new Promise((resolve) => {
sinon.stub(application.featuresService, 'migrateFeatureRepoToUserSetting').callsFake(resolve)
})
await application.itemManager.createItem(
ContentType.ExtensionRepo,
FillItemContent({
url: `https://extensions.standardnotes.org/${extensionKey}`,
}),
)
await promise
})
it('signing into account with ext repo should migrate it', async () => {
sinon.stub(application.apiService, 'isThirdPartyHostUsed').callsFake(() => {
return false
})
/** Attach an ExtensionRepo object to an account, but prevent it from being migrated.
* Then sign out, sign back in, and ensure the item is migrated. */
/** Prevent migration from running */
sinon
.stub(application.featuresService, 'migrateFeatureRepoToUserSetting')
// eslint-disable-next-line @typescript-eslint/no-empty-function
.callsFake(() => {})
const extensionKey = UuidGenerator.GenerateUuid().split('-').join('')
await application.itemManager.createItem(
ContentType.ExtensionRepo,
FillItemContent({
url: `https://extensions.standardnotes.org/${extensionKey}`,
}),
true,
)
await application.sync.sync()
application = await Factory.signOutApplicationAndReturnNew(application)
sinon.restore()
sinon.stub(application.apiService, 'isThirdPartyHostUsed').callsFake(() => {
return false
})
const promise = new Promise((resolve) => {
sinon.stub(application.featuresService, 'migrateFeatureRepoToUserSetting').callsFake(resolve)
})
await Factory.loginToApplication({
application,
email,
password,
})
await promise
})
it('having an ext repo with no account, then signing into account, should migrate it', async () => {
application = await Factory.signOutApplicationAndReturnNew(application)
sinon.stub(application.apiService, 'isThirdPartyHostUsed').callsFake(() => {
return false
})
const extensionKey = UuidGenerator.GenerateUuid().split('-').join('')
await application.itemManager.createItem(
ContentType.ExtensionRepo,
FillItemContent({
url: `https://extensions.standardnotes.org/${extensionKey}`,
}),
true,
)
await application.sync.sync()
const promise = new Promise((resolve) => {
sinon.stub(application.featuresService, 'migrateFeatureRepoToUserSetting').callsFake(resolve)
})
await Factory.loginToApplication({
application,
email,
password,
})
await promise
})
it.skip('migrated ext repo should have property indicating it was migrated', async () => {
sinon.stub(application.apiService, 'isThirdPartyHostUsed').callsFake(() => {
return false
})
expect(await application.settings.getDoesSensitiveSettingExist(SettingName.ExtensionKey)).to.equal(false)
const extensionKey = UuidGenerator.GenerateUuid().split('-').join('')
const promise = new Promise((resolve) => {
application.streamItems(ContentType.ExtensionRepo, ({ changed }) => {
for (const item of changed) {
if (item.content.migratedToUserSetting) {
resolve()
}
}
})
})
await application.itemManager.createItem(
ContentType.ExtensionRepo,
FillItemContent({
url: `https://extensions.standardnotes.org/${extensionKey}`,
}),
)
await promise
})
})
describe('offline features migration', () => {
it('previous extension repo should be migrated to offline feature repo', async () => {
application = await Factory.signOutApplicationAndReturnNew(application)
const extensionKey = UuidGenerator.GenerateUuid().split('-').join('')
await application.itemManager.createItem(
ContentType.ExtensionRepo,
FillItemContent({
url: `https://extensions.standardnotes.org/${extensionKey}`,
}),
true,
)
await application.sync.sync()
const repo = application.featuresService.getOfflineRepo()
expect(repo.migratedToOfflineEntitlements).to.equal(true)
expect(repo.offlineFeaturesUrl).to.equal('https://api.standardnotes.com/v1/offline/features')
expect(repo.offlineKey).to.equal(extensionKey)
})
})
})

View File

@@ -0,0 +1,188 @@
import * as Factory from './lib/factory.js'
import * as Utils from './lib/Utils.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('files', function () {
this.timeout(Factory.TwentySecondTimeout)
let application
let context
let fileService
let itemManager
beforeEach(function () {
localStorage.clear()
})
const setup = async ({ fakeCrypto, subscription = true }) => {
if (fakeCrypto) {
context = await Factory.createAppContextWithFakeCrypto()
} else {
context = await Factory.createAppContextWithRealCrypto()
}
await context.launch()
application = context.application
fileService = context.application.fileService
itemManager = context.application.itemManager
await Factory.registerUserToApplication({
application: context.application,
email: context.email,
password: context.password,
})
if (subscription) {
await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', {
userEmail: context.email,
subscriptionId: 1,
subscriptionName: 'PLUS_PLAN',
subscriptionExpiresAt: (new Date().getTime() + 3_600_000) * 1_000,
timestamp: Date.now(),
offline: false,
})
await Factory.sleep(0.25)
}
}
afterEach(async function () {
await Factory.safeDeinit(application)
localStorage.clear()
})
const uploadFile = async (fileService, buffer, name, ext, chunkSize) => {
const operation = await fileService.beginNewFileUpload()
let chunkId = 1
for (let i = 0; i < buffer.length; i += chunkSize) {
const readUntil = i + chunkSize > buffer.length ? buffer.length : i + chunkSize
const chunk = buffer.slice(i, readUntil)
const isFinalChunk = readUntil === buffer.length
const error = await fileService.pushBytesForUpload(operation, chunk, chunkId++, isFinalChunk)
if (error) {
throw new Error('Could not upload file chunk')
}
}
const file = await fileService.finishUpload(operation, name, ext)
return file
}
const downloadFile = async (fileService, itemManager, remoteIdentifier) => {
const file = itemManager.getItems(ContentType.File).find((file) => file.remoteIdentifier === remoteIdentifier)
let receivedBytes = new Uint8Array()
await fileService.downloadFile(file, (decryptedBytes) => {
receivedBytes = new Uint8Array([...receivedBytes, ...decryptedBytes])
})
return receivedBytes
}
it('should create valet token from server', async function () {
await setup({ fakeCrypto: true, subscription: true })
const remoteIdentifier = Utils.generateUuid()
const token = await application.apiService.createFileValetToken(remoteIdentifier, 'write')
expect(token.length).to.be.above(0)
})
it('should not create valet token from server when user has no subscription', async function () {
await setup({ fakeCrypto: true, subscription: false })
const remoteIdentifier = Utils.generateUuid()
const tokenOrError = await application.apiService.createFileValetToken(remoteIdentifier, 'write')
expect(tokenOrError.tag).to.equal('no-subscription')
})
it('should not create valet token from server when user has an expired subscription', async function () {
await setup({ fakeCrypto: true, subscription: false })
await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', {
userEmail: context.email,
subscriptionId: 1,
subscriptionName: 'PLUS_PLAN',
subscriptionExpiresAt: (new Date().getTime() - 3_600_000) * 1_000,
timestamp: Date.now(),
offline: false,
})
await Factory.sleep(0.25)
const remoteIdentifier = Utils.generateUuid()
const tokenOrError = await application.apiService.createFileValetToken(remoteIdentifier, 'write')
expect(tokenOrError.tag).to.equal('expired-subscription')
})
it('creating two upload sessions successively should succeed', async function () {
await setup({ fakeCrypto: true, subscription: true })
const firstToken = await application.apiService.createFileValetToken(Utils.generateUuid(), 'write')
const firstSession = await application.apiService.startUploadSession(firstToken)
expect(firstSession.uploadId).to.be.ok
const secondToken = await application.apiService.createFileValetToken(Utils.generateUuid(), 'write')
const secondSession = await application.apiService.startUploadSession(secondToken)
expect(secondSession.uploadId).to.be.ok
})
it('should encrypt and upload small file', async function () {
await setup({ fakeCrypto: false, subscription: true })
const response = await fetch('/packages/snjs/mocha/assets/small_file.md')
const buffer = new Uint8Array(await response.arrayBuffer())
const file = await uploadFile(fileService, buffer, 'my-file', 'md', 1000)
const downloadedBytes = await downloadFile(fileService, itemManager, file.remoteIdentifier)
expect(downloadedBytes).to.eql(buffer)
})
it('should encrypt and upload big file', async function () {
await setup({ fakeCrypto: false, subscription: true })
const response = await fetch('/packages/snjs/mocha/assets/two_mb_file.md')
const buffer = new Uint8Array(await response.arrayBuffer())
const file = await uploadFile(fileService, buffer, 'my-file', 'md', 100000)
const downloadedBytes = await downloadFile(fileService, itemManager, file.remoteIdentifier)
expect(downloadedBytes).to.eql(buffer)
})
it('should delete file', async function () {
await setup({ fakeCrypto: false, subscription: true })
const response = await fetch('/packages/snjs/mocha/assets/small_file.md')
const buffer = new Uint8Array(await response.arrayBuffer())
const file = await uploadFile(fileService, buffer, 'my-file', 'md', 1000)
const error = await fileService.deleteFile(file)
expect(error).to.not.be.ok
expect(itemManager.findItem(file.uuid)).to.not.be.ok
const downloadError = await fileService.downloadFile(file)
expect(downloadError).to.be.ok
})
it.skip('should cancel file download', async function () {
await setup({ fakeCrypto: false, subscription: true })
// ...
})
})

View File

@@ -0,0 +1,410 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
import { createNoteParams } from './lib/Items.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('history manager', () => {
const largeCharacterChange = 25
const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
beforeEach(function () {
localStorage.clear()
})
afterEach(function () {
localStorage.clear()
})
describe('session', function () {
beforeEach(async function () {
this.application = await Factory.createInitAppWithFakeCrypto()
this.historyManager = this.application.historyManager
this.payloadManager = this.application.payloadManager
/** Automatically optimize after every revision by setting this to 0 */
this.historyManager.itemRevisionThreshold = 0
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
})
function setTextAndSync(application, item, text) {
return application.mutator.changeAndSaveItem(
item,
(mutator) => {
mutator.text = text
},
undefined,
undefined,
syncOptions,
)
}
function deleteCharsFromString(string, amount) {
return string.substring(0, string.length - amount)
}
it('create basic history entries 1', async function () {
const item = await Factory.createSyncedNote(this.application)
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(0)
/** Sync with same contents, should not create new entry */
await Factory.markDirtyAndSyncItem(this.application, item)
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(0)
/** Sync with different contents, should create new entry */
await this.application.mutator.changeAndSaveItem(
item,
(mutator) => {
mutator.title = Math.random()
},
undefined,
undefined,
syncOptions,
)
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(1)
})
it('first change should create revision with previous value', async function () {
const identifier = this.application.identifier
const item = await Factory.createSyncedNote(this.application)
/** Simulate loading new application session */
const context = await Factory.createAppContext({ identifier })
await context.launch()
expect(context.application.historyManager.sessionHistoryForItem(item).length).to.equal(0)
await context.application.mutator.changeAndSaveItem(
item,
(mutator) => {
mutator.title = Math.random()
},
undefined,
undefined,
syncOptions,
)
const entries = context.application.historyManager.sessionHistoryForItem(item)
expect(entries.length).to.equal(1)
expect(entries[0].payload.content.title).to.equal(item.content.title)
await context.deinit()
})
it('creating new item and making 1 change should create 0 revisions', async function () {
const context = await Factory.createAppContext()
await context.launch()
const item = await context.application.mutator.createTemplateItem(ContentType.Note, {
references: [],
})
await context.application.mutator.insertItem(item)
expect(context.application.historyManager.sessionHistoryForItem(item).length).to.equal(0)
await context.application.mutator.changeAndSaveItem(
item,
(mutator) => {
mutator.title = Math.random()
},
undefined,
undefined,
syncOptions,
)
expect(context.application.historyManager.sessionHistoryForItem(item).length).to.equal(0)
await context.deinit()
})
it('should optimize basic entries', async function () {
let item = await Factory.createSyncedNote(this.application)
/**
* Add 1 character. This typically would be discarded as an entry, but it
* won't here because it's the first change, which we want to keep.
*/
await setTextAndSync(this.application, item, item.content.text + '1')
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(1)
/**
* Changing it by one character should keep this entry,
* since it's now the last (and will keep the first)
*/
item = await setTextAndSync(this.application, item, item.content.text + '2')
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(2)
/**
* Change it over the largeCharacterChange threshold. It should keep this
* revision, but now remove the previous revision, since it's no longer
* the last, and is a small change.
*/
item = await setTextAndSync(
this.application,
item,
item.content.text + Factory.randomString(largeCharacterChange + 1),
)
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(2)
item = await setTextAndSync(
this.application,
item,
item.content.text + Factory.randomString(largeCharacterChange + 1),
)
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(2)
/** Delete over threshold text. */
item = await setTextAndSync(
this.application,
item,
deleteCharsFromString(item.content.text, largeCharacterChange + 1),
)
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(3)
/**
* Delete just 1 character. It should now retain the previous revision, as well as the
* one previous to that.
*/
item = await setTextAndSync(this.application, item, deleteCharsFromString(item.content.text, 1))
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(4)
item = await setTextAndSync(this.application, item, deleteCharsFromString(item.content.text, 1))
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(5)
})
it('should keep the entry right before a large deletion, regardless of its delta', async function () {
const payload = new DecryptedPayload(
createNoteParams({
text: Factory.randomString(100),
}),
)
let item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
await this.application.itemManager.setItemDirty(item)
await this.application.syncService.sync(syncOptions)
/** It should keep the first and last by default */
item = await setTextAndSync(this.application, item, item.content.text)
item = await setTextAndSync(this.application, item, item.content.text + Factory.randomString(1))
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(2)
item = await setTextAndSync(
this.application,
item,
deleteCharsFromString(item.content.text, largeCharacterChange + 1),
)
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(2)
item = await setTextAndSync(this.application, item, item.content.text + Factory.randomString(1))
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(3)
item = await setTextAndSync(
this.application,
item,
item.content.text + Factory.randomString(largeCharacterChange + 1),
)
expect(this.historyManager.sessionHistoryForItem(item).length).to.equal(4)
})
it('entries should be ordered from newest to oldest', async function () {
const payload = new DecryptedPayload(
createNoteParams({
text: Factory.randomString(200),
}),
)
let item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
await this.application.itemManager.setItemDirty(item)
await this.application.syncService.sync(syncOptions)
item = await setTextAndSync(this.application, item, item.content.text + Factory.randomString(1))
item = await setTextAndSync(
this.application,
item,
deleteCharsFromString(item.content.text, largeCharacterChange + 1),
)
item = await setTextAndSync(this.application, item, item.content.text + Factory.randomString(1))
item = await setTextAndSync(
this.application,
item,
item.content.text + Factory.randomString(largeCharacterChange + 1),
)
/** First entry should be the latest revision. */
const latestRevision = this.historyManager.sessionHistoryForItem(item)[0]
/** Last entry should be the initial revision. */
const initialRevision =
this.historyManager.sessionHistoryForItem(item)[this.historyManager.sessionHistoryForItem(item).length - 1]
expect(latestRevision).to.not.equal(initialRevision)
expect(latestRevision.textCharDiffLength).to.equal(1)
expect(initialRevision.textCharDiffLength).to.equal(200)
/** Finally, the latest revision updated_at value date should be more recent than the initial revision one. */
expect(latestRevision.itemFromPayload().userModifiedDate).to.be.greaterThan(
initialRevision.itemFromPayload().userModifiedDate,
)
}).timeout(10000)
it('unsynced entries should use payload created_at for preview titles', async function () {
const payload = Factory.createNotePayload()
await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
const item = this.application.items.findItem(payload.uuid)
await this.application.mutator.changeAndSaveItem(
item,
(mutator) => {
mutator.title = Math.random()
},
undefined,
undefined,
syncOptions,
)
const historyItem = this.historyManager.sessionHistoryForItem(item)[0]
expect(historyItem.previewTitle()).to.equal(historyItem.payload.created_at.toLocaleString())
})
})
describe('remote', function () {
beforeEach(async function () {
this.application = await Factory.createInitAppWithFakeCrypto()
this.historyManager = this.application.historyManager
this.payloadManager = this.application.payloadManager
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
})
it('response from server should be empty if not signed in', async function () {
await this.application.user.signOut()
this.application = await Factory.createInitAppWithFakeCrypto()
this.historyManager = this.application.historyManager
this.payloadManager = this.application.payloadManager
const item = await Factory.createSyncedNote(this.application)
await this.application.syncService.sync(syncOptions)
const itemHistory = await this.historyManager.remoteHistoryForItem(item)
expect(itemHistory).to.be.undefined
})
it('create basic history entries 2', async function () {
const item = await Factory.createSyncedNote(this.application)
let itemHistory = await this.historyManager.remoteHistoryForItem(item)
/** Server history should save initial revision */
expect(itemHistory).to.be.ok
expect(itemHistory.length).to.equal(1)
/** Sync within 5 minutes, should not create a new entry */
await Factory.markDirtyAndSyncItem(this.application, item)
itemHistory = await this.historyManager.remoteHistoryForItem(item)
expect(itemHistory.length).to.equal(1)
/** Sync with different contents, should not create a new entry */
await this.application.mutator.changeAndSaveItem(
item,
(mutator) => {
mutator.title = Math.random()
},
undefined,
undefined,
syncOptions,
)
itemHistory = await this.historyManager.remoteHistoryForItem(item)
expect(itemHistory.length).to.equal(1)
})
it('returns revisions from server', async function () {
let item = await Factory.createSyncedNote(this.application)
await Factory.sleep(Factory.ServerRevisionFrequency)
/** Sync with different contents, should create new entry */
const newTitleAfterFirstChange = `The title should be: ${Math.random()}`
await this.application.mutator.changeAndSaveItem(
item,
(mutator) => {
mutator.title = newTitleAfterFirstChange
},
undefined,
undefined,
syncOptions,
)
let itemHistory = await this.historyManager.remoteHistoryForItem(item)
expect(itemHistory.length).to.equal(2)
const oldestEntry = lastElement(itemHistory)
let revisionFromServer = await this.historyManager.fetchRemoteRevision(item, oldestEntry)
expect(revisionFromServer).to.be.ok
let payloadFromServer = revisionFromServer.payload
expect(payloadFromServer.errorDecrypting).to.be.undefined
expect(payloadFromServer.uuid).to.eq(item.payload.uuid)
expect(payloadFromServer.content).to.eql(item.payload.content)
item = this.application.itemManager.findItem(item.uuid)
expect(payloadFromServer.content).to.not.eql(item.payload.content)
})
it('duplicate revisions should not have the originals uuid', async function () {
const note = await Factory.createSyncedNote(this.application)
await Factory.markDirtyAndSyncItem(this.application, note)
const dupe = await this.application.itemManager.duplicateItem(note, true)
await Factory.markDirtyAndSyncItem(this.application, dupe)
const dupeHistory = await this.historyManager.remoteHistoryForItem(dupe)
const dupeRevision = await this.historyManager.fetchRemoteRevision(dupe, dupeHistory[0])
expect(dupeRevision.payload.uuid).to.equal(dupe.uuid)
})
it.skip('revisions count matches original for duplicated items', async function () {
/**
* We can't handle duplicate item revision because the server copies over revisions
* via a background job which we can't predict the timing of. This test is thus invalid.
*/
const note = await Factory.createSyncedNote(this.application)
/** Make a few changes to note */
await Factory.sleep(Factory.ServerRevisionFrequency)
await Factory.markDirtyAndSyncItem(this.application, note)
await Factory.sleep(Factory.ServerRevisionFrequency)
await Factory.markDirtyAndSyncItem(this.application, note)
await Factory.sleep(Factory.ServerRevisionFrequency)
await Factory.markDirtyAndSyncItem(this.application, note)
const dupe = await this.application.itemManager.duplicateItem(note, true)
await Factory.markDirtyAndSyncItem(this.application, dupe)
const expectedRevisions = 3
const noteHistory = await this.historyManager.remoteHistoryForItem(note)
const dupeHistory = await this.historyManager.remoteHistoryForItem(dupe)
expect(noteHistory.length).to.equal(expectedRevisions)
expect(dupeHistory.length).to.equal(expectedRevisions)
})
it.skip('can decrypt revisions for duplicate_of items', async function () {
/**
* We can't handle duplicate item revision because the server copies over revisions
* via a background job which we can't predict the timing of. This test is thus invalid.
*/
const note = await Factory.createSyncedNote(this.application)
await Factory.sleep(Factory.ServerRevisionFrequency)
const changedText = `${Math.random()}`
/** Make a few changes to note */
await this.application.mutator.changeAndSaveItem(note, (mutator) => {
mutator.title = changedText
})
await Factory.markDirtyAndSyncItem(this.application, note)
const dupe = await this.application.itemManager.duplicateItem(note, true)
await Factory.markDirtyAndSyncItem(this.application, dupe)
const itemHistory = await this.historyManager.remoteHistoryForItem(dupe)
expect(itemHistory.length).to.be.above(1)
const oldestRevision = lastElement(itemHistory)
const fetched = await this.historyManager.fetchRemoteRevision(dupe, oldestRevision)
expect(fetched.payload.errorDecrypting).to.not.be.ok
expect(fetched.payload.content.title).to.equal(changedText)
})
})
})

View File

@@ -0,0 +1,93 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('item', () => {
beforeEach(async function () {
this.createBarePayload = () => {
return new DecryptedPayload({
uuid: '123',
content_type: ContentType.Note,
content: {
title: 'hello',
},
})
}
this.createNote = () => {
return new DecryptedItem(this.createBarePayload())
}
this.createTag = (notes = []) => {
const references = notes.map((note) => {
return {
uuid: note.uuid,
content_type: note.content_type,
}
})
return new SNTag(
new DecryptedPayload({
uuid: Factory.generateUuidish(),
content_type: ContentType.Tag,
content: {
title: 'thoughts',
references: references,
},
}),
)
}
})
it('constructing without uuid should throw', function () {
let error
try {
new DecryptedItem({})
} catch (e) {
error = e
}
expect(error).to.be.ok
})
it('healthy constructor', function () {
const item = this.createNote()
expect(item).to.be.ok
expect(item.payload).to.be.ok
})
it('user modified date should be ok', function () {
const item = this.createNote()
expect(item.userModifiedDate).to.be.ok
})
it('has relationship with item true', function () {
const note = this.createNote()
const tag = this.createTag()
expect(tag.isReferencingItem(note)).to.equal(false)
})
it('has relationship with item true', function () {
const note = this.createNote()
const tag = this.createTag([note])
expect(tag.isReferencingItem(note)).to.equal(true)
})
it('getDomainData for random domain should return undefined', function () {
const note = this.createNote()
expect(note.getDomainData('random')).to.not.be.ok
})
it('getDomainData for app domain should return object', function () {
const note = this.createNote()
expect(note.getDomainData(DecryptedItem.DefaultAppDomain())).to.be.ok
})
})

View File

@@ -0,0 +1,602 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('item manager', function () {
beforeEach(async function () {
this.payloadManager = new PayloadManager()
this.itemManager = new ItemManager(this.payloadManager)
this.createNote = async () => {
return this.itemManager.createItem(ContentType.Note, {
title: 'hello',
text: 'world',
})
}
this.createTag = async (notes = []) => {
const references = notes.map((note) => {
return {
uuid: note.uuid,
content_type: note.content_type,
}
})
return this.itemManager.createItem(ContentType.Tag, {
title: 'thoughts',
references: references,
})
}
})
it('create item', async function () {
const item = await this.createNote()
expect(item).to.be.ok
expect(item.title).to.equal('hello')
})
it('emitting item through payload and marking dirty should have userModifiedDate', async function () {
const payload = Factory.createNotePayload()
const item = await this.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
const result = await this.itemManager.setItemDirty(item)
const appData = result.payload.content.appData
expect(appData[DecryptedItem.DefaultAppDomain()][AppDataField.UserModifiedDate]).to.be.ok
})
it('find items with valid uuid', async function () {
const item = await this.createNote()
const results = await this.itemManager.findItems([item.uuid])
expect(results.length).to.equal(1)
expect(results[0]).to.equal(item)
})
it('find items with invalid uuid no blanks', async function () {
const results = await this.itemManager.findItems([Factory.generateUuidish()])
expect(results.length).to.equal(0)
})
it('find items with invalid uuid include blanks', async function () {
const includeBlanks = true
const results = await this.itemManager.findItemsIncludingBlanks([Factory.generateUuidish()])
expect(results.length).to.equal(1)
expect(results[0]).to.not.be.ok
})
it('item state', async function () {
await this.createNote()
expect(this.itemManager.items.length).to.equal(1)
expect(this.itemManager.getDisplayableNotes().length).to.equal(1)
})
it('find item', async function () {
const item = await this.createNote()
const foundItem = this.itemManager.findItem(item.uuid)
expect(foundItem).to.be.ok
})
it('reference map', async function () {
const note = await this.createNote()
const tag = await this.createTag([note])
expect(this.itemManager.collection.referenceMap.directMap[tag.uuid]).to.eql([note.uuid])
})
it('inverse reference map', async function () {
const note = await this.createNote()
const tag = await this.createTag([note])
expect(this.itemManager.collection.referenceMap.inverseMap[note.uuid]).to.eql([tag.uuid])
})
it('inverse reference map should not have duplicates', async function () {
const note = await this.createNote()
const tag = await this.createTag([note])
await this.itemManager.changeItem(tag)
expect(this.itemManager.collection.referenceMap.inverseMap[note.uuid]).to.eql([tag.uuid])
})
it('deleting from reference map', async function () {
const note = await this.createNote()
const tag = await this.createTag([note])
await this.itemManager.setItemToBeDeleted(note)
expect(this.itemManager.collection.referenceMap.directMap[tag.uuid]).to.eql([])
expect(this.itemManager.collection.referenceMap.inverseMap[note.uuid].length).to.equal(0)
})
it('deleting referenced item should update referencing item references', async function () {
const note = await this.createNote()
let tag = await this.createTag([note])
await this.itemManager.setItemToBeDeleted(note)
tag = this.itemManager.findItem(tag.uuid)
expect(tag.content.references.length).to.equal(0)
})
it('removing relationship should update reference map', async function () {
const note = await this.createNote()
const tag = await this.createTag([note])
await this.itemManager.changeItem(tag, (mutator) => {
mutator.removeItemAsRelationship(note)
})
expect(this.itemManager.collection.referenceMap.directMap[tag.uuid]).to.eql([])
expect(this.itemManager.collection.referenceMap.inverseMap[note.uuid]).to.eql([])
})
it('emitting discardable payload should remove it from our collection', async function () {
const note = await this.createNote()
const payload = new DeletedPayload({
...note.payload.ejected(),
content: undefined,
deleted: true,
dirty: false,
})
expect(payload.discardable).to.equal(true)
await this.itemManager.emitItemFromPayload(payload)
expect(this.itemManager.findItem(note.uuid)).to.not.be.ok
})
it('items that reference item', async function () {
const note = await this.createNote()
const tag = await this.createTag([note])
const itemsThatReference = this.itemManager.itemsReferencingItem(note)
expect(itemsThatReference.length).to.equal(1)
expect(itemsThatReference[0]).to.equal(tag)
})
it('observer', async function () {
const observed = []
this.itemManager.addObserver(ContentType.Any, ({ changed, inserted, removed, source, sourceKey }) => {
observed.push({ changed, inserted, removed, source, sourceKey })
})
const note = await this.createNote()
const tag = await this.createTag([note])
expect(observed.length).to.equal(2)
const firstObserved = observed[0]
expect(firstObserved.inserted).to.eql([note])
const secondObserved = observed[1]
expect(secondObserved.inserted).to.eql([tag])
})
it('change existing item', async function () {
const note = await this.createNote()
const newTitle = String(Math.random())
await this.itemManager.changeItem(note, (mutator) => {
mutator.title = newTitle
})
const latestVersion = this.itemManager.findItem(note.uuid)
expect(latestVersion.title).to.equal(newTitle)
})
it('change non-existant item through uuid should fail', async function () {
const note = await this.itemManager.createTemplateItem(ContentType.Note, {
title: 'hello',
text: 'world',
})
const changeFn = async () => {
const newTitle = String(Math.random())
return this.itemManager.changeItem(note, (mutator) => {
mutator.title = newTitle
})
}
await Factory.expectThrowsAsync(() => changeFn(), 'Attempting to change non-existant item')
})
it('set items dirty', async function () {
const note = await this.createNote()
await this.itemManager.setItemDirty(note)
const dirtyItems = this.itemManager.getDirtyItems()
expect(dirtyItems.length).to.equal(1)
expect(dirtyItems[0].uuid).to.equal(note.uuid)
expect(dirtyItems[0].dirty).to.equal(true)
})
it('dirty items should not include errored items', async function () {
const note = await this.itemManager.setItemDirty(await this.createNote())
const errorred = new EncryptedPayload({
...note.payload,
content: '004:...',
errorDecrypting: true,
})
await this.itemManager.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged)
const dirtyItems = this.itemManager.getDirtyItems()
expect(dirtyItems.length).to.equal(0)
})
it('dirty items should include errored items if they are being deleted', async function () {
const note = await this.itemManager.setItemDirty(await this.createNote())
const errorred = new DeletedPayload({
...note.payload,
content: undefined,
errorDecrypting: true,
deleted: true,
})
await this.itemManager.emitItemsFromPayloads([errorred], PayloadEmitSource.LocalChanged)
const dirtyItems = this.itemManager.getDirtyItems()
expect(dirtyItems.length).to.equal(1)
})
describe('duplicateItem', async function () {
const sandbox = sinon.createSandbox()
beforeEach(async function () {
this.emitPayloads = sandbox.spy(this.itemManager.payloadManager, 'emitPayloads')
})
afterEach(async function () {
sandbox.restore()
})
it('should duplicate the item and set the duplicate_of property', async function () {
const note = await this.createNote()
await this.itemManager.duplicateItem(note)
sinon.assert.calledTwice(this.emitPayloads)
const originalNote = this.itemManager.getDisplayableNotes()[0]
const duplicatedNote = this.itemManager.getDisplayableNotes()[1]
expect(this.itemManager.items.length).to.equal(2)
expect(this.itemManager.getDisplayableNotes().length).to.equal(2)
expect(originalNote.uuid).to.not.equal(duplicatedNote.uuid)
expect(originalNote.uuid).to.equal(duplicatedNote.duplicateOf)
expect(originalNote.uuid).to.equal(duplicatedNote.payload.duplicate_of)
expect(duplicatedNote.conflictOf).to.be.undefined
expect(duplicatedNote.payload.content.conflict_of).to.be.undefined
})
it('should duplicate the item and set the duplicate_of and conflict_of properties', async function () {
const note = await this.createNote()
await this.itemManager.duplicateItem(note, true)
sinon.assert.calledTwice(this.emitPayloads)
const originalNote = this.itemManager.getDisplayableNotes()[0]
const duplicatedNote = this.itemManager.getDisplayableNotes()[1]
expect(this.itemManager.items.length).to.equal(2)
expect(this.itemManager.getDisplayableNotes().length).to.equal(2)
expect(originalNote.uuid).to.not.equal(duplicatedNote.uuid)
expect(originalNote.uuid).to.equal(duplicatedNote.duplicateOf)
expect(originalNote.uuid).to.equal(duplicatedNote.payload.duplicate_of)
expect(originalNote.uuid).to.equal(duplicatedNote.conflictOf)
expect(originalNote.uuid).to.equal(duplicatedNote.payload.content.conflict_of)
})
it('duplicate item with relationships', async function () {
const note = await this.createNote()
const tag = await this.createTag([note])
const duplicate = await this.itemManager.duplicateItem(tag)
expect(duplicate.content.references).to.have.length(1)
expect(this.itemManager.items).to.have.length(3)
expect(this.itemManager.getDisplayableTags()).to.have.length(2)
})
it('adds duplicated item as a relationship to items referencing it', async function () {
const note = await this.createNote()
let tag = await this.createTag([note])
const duplicateNote = await this.itemManager.duplicateItem(note)
expect(tag.content.references).to.have.length(1)
tag = this.itemManager.findItem(tag.uuid)
const references = tag.content.references.map((ref) => ref.uuid)
expect(references).to.have.length(2)
expect(references).to.include(note.uuid, duplicateNote.uuid)
})
it('duplicates item with additional content', async function () {
const note = await this.itemManager.createItem(ContentType.Note, {
title: 'hello',
text: 'world',
})
const duplicateNote = await this.itemManager.duplicateItem(note, false, {
title: 'hello (copy)',
})
expect(duplicateNote.title).to.equal('hello (copy)')
expect(duplicateNote.text).to.equal('world')
})
})
it('set item deleted', async function () {
const note = await this.createNote()
await this.itemManager.setItemToBeDeleted(note)
/** Items should never be mutated directly */
expect(note.deleted).to.not.be.ok
const latestVersion = this.payloadManager.findOne(note.uuid)
expect(latestVersion.deleted).to.equal(true)
expect(latestVersion.dirty).to.equal(true)
expect(latestVersion.content).to.not.be.ok
/** Deleted items do not show up in item manager's public interface */
expect(this.itemManager.items.length).to.equal(0)
expect(this.itemManager.getDisplayableNotes().length).to.equal(0)
})
it('system smart views', async function () {
expect(this.itemManager.systemSmartViews.length).to.be.above(0)
})
it('find tag by title', async function () {
const tag = await this.createTag()
expect(this.itemManager.findTagByTitle(tag.title)).to.be.ok
})
it('find tag by title should be case insensitive', async function () {
const tag = await this.createTag()
expect(this.itemManager.findTagByTitle(tag.title.toUpperCase())).to.be.ok
})
it('find or create tag by title', async function () {
const title = 'foo'
expect(await this.itemManager.findOrCreateTagByTitle(title)).to.be.ok
})
it('note count', async function () {
await this.createNote()
expect(this.itemManager.noteCount).to.equal(1)
})
it('trash', async function () {
const note = await this.createNote()
const versionTwo = await this.itemManager.changeItem(note, (mutator) => {
mutator.trashed = true
})
expect(this.itemManager.trashSmartView).to.be.ok
expect(versionTwo.trashed).to.equal(true)
expect(versionTwo.dirty).to.equal(true)
expect(versionTwo.content).to.be.ok
expect(this.itemManager.items.length).to.equal(1)
expect(this.itemManager.trashedItems.length).to.equal(1)
await this.itemManager.emptyTrash()
const versionThree = this.payloadManager.findOne(note.uuid)
expect(versionThree.deleted).to.equal(true)
expect(this.itemManager.trashedItems.length).to.equal(0)
})
it('remove all items from memory', async function () {
const observed = []
this.itemManager.addObserver(ContentType.Any, ({ changed, inserted, removed, ignored }) => {
observed.push({ changed, inserted, removed, ignored })
})
await this.createNote()
await this.itemManager.removeAllItemsFromMemory()
const deletionEvent = observed[1]
expect(deletionEvent.removed[0].deleted).to.equal(true)
expect(this.itemManager.items.length).to.equal(0)
})
it('remove item locally', async function () {
const observed = []
this.itemManager.addObserver(ContentType.Any, ({ changed, inserted, removed, ignored }) => {
observed.push({ changed, inserted, removed, ignored })
})
const note = await this.createNote()
await this.itemManager.removeItemLocally(note)
expect(observed.length).to.equal(1)
expect(this.itemManager.findItem(note.uuid)).to.not.be.ok
})
it('emitting a payload from within observer should queue to end', async function () {
/**
* From within an item observer, we want to emit some changes and await them.
* We expect that the end result is that whatever was most recently emitted,
* is propagated to listeners after any pending observation events. That is, when you
* emit items, it should be done serially, so that emitting while you're emitting does
* not interrupt the current emission, but instead queues it. This is so that changes
* are not propagated out of order.
*/
const payload = Factory.createNotePayload()
const changedTitle = 'changed title'
let didEmit = false
let latestVersion
this.itemManager.addObserver(ContentType.Note, ({ changed, inserted }) => {
const all = changed.concat(inserted)
if (!didEmit) {
didEmit = true
const changedPayload = payload.copy({
content: {
...payload.content,
title: changedTitle,
},
})
this.itemManager.emitItemFromPayload(changedPayload)
}
latestVersion = all[0]
})
await this.itemManager.emitItemFromPayload(payload)
expect(latestVersion.title).to.equal(changedTitle)
})
describe('searchTags', async function () {
it('should return tag with query matching title', async function () {
const tag = await this.itemManager.findOrCreateTagByTitle('tag')
const results = this.itemManager.searchTags('tag')
expect(results).lengthOf(1)
expect(results[0].title).to.equal(tag.title)
})
it('should return all tags with query partially matching title', async function () {
const firstTag = await this.itemManager.findOrCreateTagByTitle('tag one')
const secondTag = await this.itemManager.findOrCreateTagByTitle('tag two')
const results = this.itemManager.searchTags('tag')
expect(results).lengthOf(2)
expect(results[0].title).to.equal(firstTag.title)
expect(results[1].title).to.equal(secondTag.title)
})
it('should be case insensitive', async function () {
const tag = await this.itemManager.findOrCreateTagByTitle('Tag')
const results = this.itemManager.searchTags('tag')
expect(results).lengthOf(1)
expect(results[0].title).to.equal(tag.title)
})
it('should return tag with query matching delimiter separated component', async function () {
const tag = await this.itemManager.findOrCreateTagByTitle('parent.child')
const results = this.itemManager.searchTags('child')
expect(results).lengthOf(1)
expect(results[0].title).to.equal(tag.title)
})
it('should return tags with matching query including delimiter', async function () {
const tag = await this.itemManager.findOrCreateTagByTitle('parent.child')
const results = this.itemManager.searchTags('parent.chi')
expect(results).lengthOf(1)
expect(results[0].title).to.equal(tag.title)
})
it('should return tags in natural order', async function () {
const firstTag = await this.itemManager.findOrCreateTagByTitle('tag 100')
const secondTag = await this.itemManager.findOrCreateTagByTitle('tag 2')
const thirdTag = await this.itemManager.findOrCreateTagByTitle('tag b')
const fourthTag = await this.itemManager.findOrCreateTagByTitle('tag a')
const results = this.itemManager.searchTags('tag')
expect(results).lengthOf(4)
expect(results[0].title).to.equal(secondTag.title)
expect(results[1].title).to.equal(firstTag.title)
expect(results[2].title).to.equal(fourthTag.title)
expect(results[3].title).to.equal(thirdTag.title)
})
it('should not return tags associated with note', async function () {
const firstTag = await this.itemManager.findOrCreateTagByTitle('tag one')
const secondTag = await this.itemManager.findOrCreateTagByTitle('tag two')
const note = await this.createNote()
await this.itemManager.changeItem(firstTag, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(note)
})
const results = this.itemManager.searchTags('tag', note)
expect(results).lengthOf(1)
expect(results[0].title).to.equal(secondTag.title)
})
})
describe('smart views', async function () {
it('all view should not include archived notes by default', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.archived = true
})
this.itemManager.setPrimaryItemDisplayOptions({
views: [this.itemManager.allNotesSmartView],
})
expect(this.itemManager.getDisplayableNotes().length).to.equal(0)
})
it('archived view should not include trashed notes by default', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.archived = true
mutator.trashed = true
})
this.itemManager.setPrimaryItemDisplayOptions({
views: [this.itemManager.archivedSmartView],
})
expect(this.itemManager.getDisplayableNotes().length).to.equal(0)
})
it('trashed view should include archived notes by default', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.archived = true
mutator.trashed = true
})
this.itemManager.setPrimaryItemDisplayOptions({
views: [this.itemManager.trashSmartView],
})
expect(this.itemManager.getDisplayableNotes().length).to.equal(1)
})
})
describe('getSortedTagsForNote', async function () {
it('should return tags associated with a note in natural order', async function () {
const tags = [
await this.itemManager.findOrCreateTagByTitle('tag 100'),
await this.itemManager.findOrCreateTagByTitle('tag 2'),
await this.itemManager.findOrCreateTagByTitle('tag b'),
await this.itemManager.findOrCreateTagByTitle('tag a'),
]
const note = await this.createNote()
tags.map(async (tag) => {
await this.itemManager.changeItem(tag, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(note)
})
})
const results = this.itemManager.getSortedTagsForNote(note)
expect(results).lengthOf(tags.length)
expect(results[0].title).to.equal(tags[1].title)
expect(results[1].title).to.equal(tags[0].title)
expect(results[2].title).to.equal(tags[3].title)
expect(results[3].title).to.equal(tags[2].title)
})
})
describe('getTagParentChain', function () {
it('should return parent tags for a tag', async function () {
const [parent, child, grandchild, _other] = await Promise.all([
this.itemManager.findOrCreateTagByTitle('parent'),
this.itemManager.findOrCreateTagByTitle('parent.child'),
this.itemManager.findOrCreateTagByTitle('parent.child.grandchild'),
this.itemManager.findOrCreateTagByTitle('some other tag'),
])
await this.itemManager.setTagParent(parent, child)
await this.itemManager.setTagParent(child, grandchild)
const results = this.itemManager.getTagParentChain(grandchild)
expect(results).lengthOf(2)
expect(results[0].uuid).to.equal(parent.uuid)
expect(results[1].uuid).to.equal(child.uuid)
})
})
})

View File

@@ -0,0 +1,86 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('key params', function () {
this.timeout(Factory.TenSecondTimeout)
before(async function () {
localStorage.clear()
})
after(async function () {
localStorage.clear()
})
it('extraneous parameters in key params should be ignored when ejecting', async function () {
const params = new SNRootKeyParams({
identifier: 'foo',
pw_cost: 110000,
pw_nonce: 'bar',
pw_salt: 'salt',
version: '003',
origination: 'registration',
created: new Date().getTime(),
hash: '123',
foo: 'bar',
})
const ejected = params.getPortableValue()
expect(ejected.hash).to.not.be.ok
expect(ejected.pw_cost).to.be.ok
expect(ejected.pw_nonce).to.be.ok
expect(ejected.pw_salt).to.be.ok
expect(ejected.version).to.be.ok
expect(ejected.origination).to.be.ok
expect(ejected.created).to.be.ok
expect(ejected.identifier).to.be.ok
})
describe('with missing version', function () {
it('should default to 002 if uses high cost', async function () {
const params = new SNRootKeyParams({
identifier: 'foo',
pw_cost: 101000,
pw_nonce: 'bar',
pw_salt: 'salt',
})
expect(params.version).to.equal('002')
})
it('should default to 001 if uses low cost', async function () {
const params = new SNRootKeyParams({
identifier: 'foo',
pw_cost: 60000,
pw_nonce: 'bar',
pw_salt: 'salt',
})
expect(params.version).to.equal('002')
})
it('should default to 002 if uses cost seen in both 001 and 002, but has no pw_nonce', async function () {
const params = new SNRootKeyParams({
identifier: 'foo',
pw_cost: 60000,
pw_nonce: undefined,
pw_salt: 'salt',
})
expect(params.version).to.equal('002')
})
it('should default to 001 if uses cost seen in both 001 and 002, but is more likely a 001 cost', async function () {
const params = new SNRootKeyParams({
identifier: 'foo',
pw_cost: 5000,
pw_nonce: 'bar',
pw_salt: 'salt',
})
expect(params.version).to.equal('001')
})
})
})

View File

@@ -0,0 +1,683 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('key recovery service', function () {
this.timeout(Factory.TwentySecondTimeout)
const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
beforeEach(function () {
localStorage.clear()
})
afterEach(function () {
localStorage.clear()
})
it('when encountering an undecryptable items key, should recover through recovery wizard', async function () {
const namespace = Factory.randomString()
const context = await Factory.createAppContextWithFakeCrypto(namespace)
const unassociatedPassword = 'randfoo'
const unassociatedIdentifier = 'foorand'
const application = context.application
await context.launch({
receiveChallenge: (challenge) => {
application.submitValuesForChallenge(challenge, [
CreateChallengeValue(challenge.prompts[0], unassociatedPassword),
])
},
})
await context.register()
const randomRootKey = await application.protocolService.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const randomItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKey: {
items: [randomItemsKey.payload],
key: randomRootKey,
},
})
const errored = await application.protocolService.decryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [encrypted],
},
})
expect(errored.errorDecrypting).to.equal(true)
await application.payloadManager.emitPayload(errored, PayloadEmitSource.LocalInserted)
await context.resolveWhenKeyRecovered(errored.uuid)
expect(application.items.findItem(errored.uuid).errorDecrypting).to.not.be.ok
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it('recovered keys with key params not matching servers should be synced if local root key does matches server', async function () {
/**
* This helps ensure server always has the most valid state,
* in case the recovery is being initiated from a server value in the first place
*/
const context = await Factory.createAppContextWithFakeCrypto()
const unassociatedPassword = 'randfoo'
const unassociatedIdentifier = 'foorand'
const application = context.application
await context.launch({
receiveChallenge: (challenge) => {
application.submitValuesForChallenge(challenge, [
CreateChallengeValue(challenge.prompts[0], unassociatedPassword),
])
},
})
await context.register()
const randomRootKey = await application.protocolService.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const randomItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey()
await application.payloadManager.emitPayload(
randomItemsKey.payload.copy({ dirty: true, dirtyIndex: getIncrementedDirtyIndex() }),
PayloadEmitSource.LocalInserted,
)
await context.sync()
const originalSyncTime = application.payloadManager.findOne(randomItemsKey.uuid).lastSyncEnd.getTime()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKey: {
items: [randomItemsKey.payload],
key: randomRootKey,
},
})
const errored = await application.protocolService.decryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [encrypted],
},
})
await application.payloadManager.emitPayload(errored, PayloadEmitSource.LocalInserted)
const recoveryPromise = context.resolveWhenKeyRecovered(errored.uuid)
await context.sync()
await recoveryPromise
expect(application.payloadManager.findOne(errored.uuid).lastSyncEnd.getTime()).to.be.above(originalSyncTime)
await context.deinit()
})
it('recovered keys with key params not matching servers should not be synced if local root key does not match server', async function () {
/**
* Assume Application A has been through these states:
* 1. Registration + Items Key A + Root Key A
* 2. Password change + Items Key B + Root Key B
* 3. Password change + Items Key C + Root Key C + Failure to correctly re-encrypt Items Key A and B with Root Key C
*
* Application B is not correctly in sync, and is only at State 1 (Registration + Items Key A)
*
* Application B receives Items Key B of Root Key B but for whatever reason ignores Items Key C of Root Key C.
*
* When it recovers Items Key B, it should not re-upload it to the server, because Application B's Root Key is not
* the current account's root key.
*/
const contextA = await Factory.createAppContextWithFakeCrypto()
await contextA.launch()
await contextA.register()
contextA.preventKeyRecoveryOfKeys()
const contextB = await Factory.createAppContextWithFakeCrypto('app-b', contextA.email, contextA.password)
await contextB.launch()
await contextB.signIn()
await contextA.changePassword('new-password-1')
const itemsKeyARootKeyB = contextA.itemsKeys[0]
const itemsKeyBRootKeyB = contextA.itemsKeys[1]
contextA.disableSyncingOfItems([itemsKeyARootKeyB.uuid, itemsKeyBRootKeyB.uuid])
await contextA.changePassword('new-password-2')
const itemsKeyCRootKeyC = contextA.itemsKeys[2]
contextB.disableKeyRecoveryServerSignIn()
contextB.preventKeyRecoveryOfKeys([itemsKeyCRootKeyC.uuid])
contextB.respondToAccountPasswordChallengeWith('new-password-1')
const recoveryPromise = Promise.all([
contextB.resolveWhenKeyRecovered(itemsKeyARootKeyB.uuid),
contextB.resolveWhenKeyRecovered(itemsKeyBRootKeyB.uuid),
])
const observedDirtyItemUuids = []
contextB.spyOnChangedItems((changed) => {
const dirty = changed.filter((i) => i.dirty)
extendArray(observedDirtyItemUuids, Uuids(dirty))
})
await contextB.sync()
await recoveryPromise
expect(observedDirtyItemUuids.includes(itemsKeyARootKeyB.uuid)).to.equal(false)
expect(observedDirtyItemUuids.includes(itemsKeyBRootKeyB.uuid)).to.equal(false)
await contextA.deinit()
await contextB.deinit()
})
it('when encountering many undecryptable items key with same key params, should only prompt once', async function () {
const namespace = Factory.randomString()
const unassociatedPassword = 'randfoo'
const unassociatedIdentifier = 'foorand'
let totalPromptCount = 0
const context = await Factory.createAppContextWithFakeCrypto(namespace)
const application = context.application
const receiveChallenge = (challenge) => {
totalPromptCount++
/** Give unassociated password when prompted */
application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], unassociatedPassword)])
}
await application.prepareForLaunch({ receiveChallenge })
await application.launch(true)
await Factory.registerUserToApplication({
application: application,
email: context.email,
password: context.password,
})
/** Create items key associated with a random root key */
const randomRootKey = await application.protocolService.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const randomItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey()
const randomItemsKey2 = await application.protocolService.operatorManager.defaultOperator().createItemsKey()
const encrypted = await application.protocolService.encryptSplit({
usesRootKey: {
items: [randomItemsKey.payload, randomItemsKey2.payload],
key: randomRootKey,
},
})
/** Attempt decryption and insert into rotation in errored state */
const decrypted = await application.protocolService.decryptSplit({
usesRootKeyWithKeyLookup: {
items: encrypted,
},
})
await application.payloadManager.emitPayloads(decrypted, PayloadEmitSource.LocalInserted)
/** Wait and allow recovery wizard to complete */
await Factory.sleep(1.5)
/** Should be decrypted now */
expect(application.items.findItem(randomItemsKey.uuid).errorDecrypting).not.be.ok
expect(application.items.findItem(randomItemsKey2.uuid).errorDecrypting).not.be.ok
expect(totalPromptCount).to.equal(1)
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it('when changing password on client B, client A should perform recovery flow', async function () {
const contextA = await Factory.createAppContextWithFakeCrypto()
await contextA.launch()
await contextA.register()
const originalItemsKey = contextA.application.items.getDisplayableItemsKeys()[0]
const contextB = await Factory.createAppContextWithFakeCrypto(
'another-namespace',
contextA.email,
contextA.password,
)
contextB.ignoreChallenges()
await contextB.launch()
await contextB.signIn()
const newPassword = `${Math.random()}`
const result = await contextB.application.changePassword(contextA.password, newPassword)
expect(result.error).to.not.be.ok
expect(contextB.application.items.getAnyItems(ContentType.ItemsKey).length).to.equal(2)
const newItemsKey = contextB.application.items.getDisplayableItemsKeys().find((k) => k.uuid !== originalItemsKey.uuid)
const note = await Factory.createSyncedNote(contextB.application)
const recoveryPromise = contextA.resolveWhenKeyRecovered(newItemsKey.uuid)
contextA.password = newPassword
await contextA.sync(syncOptions)
await recoveryPromise
/** Same previously errored key should now no longer be errored, */
expect(contextA.application.items.getAnyItems(ContentType.ItemsKey).length).to.equal(2)
for (const key of contextA.application.itemManager.getDisplayableItemsKeys()) {
expect(key.errorDecrypting).to.not.be.ok
}
const aKey = await contextA.application.protocolService.getRootKey()
const bKey = await contextB.application.protocolService.getRootKey()
expect(aKey.compare(bKey)).to.equal(true)
expect(contextA.application.items.findItem(note.uuid).errorDecrypting).to.not.be.ok
expect(contextB.application.items.findItem(note.uuid).errorDecrypting).to.not.be.ok
expect(contextA.application.syncService.isOutOfSync()).to.equal(false)
expect(contextB.application.syncService.isOutOfSync()).to.equal(false)
await contextA.deinit()
await contextB.deinit()
}).timeout(80000)
it('when items key associated with item is errored, item should be marked waiting for key', async function () {
const namespace = Factory.randomString()
const newPassword = `${Math.random()}`
const contextA = await Factory.createAppContextWithFakeCrypto(namespace)
const appA = contextA.application
await appA.prepareForLaunch({ receiveChallenge: () => {} })
await appA.launch(true)
await Factory.registerUserToApplication({
application: appA,
email: contextA.email,
password: contextA.password,
})
expect(appA.items.getItems(ContentType.ItemsKey).length).to.equal(1)
/** Create simultaneous appB signed into same account */
const appB = await Factory.createApplicationWithFakeCrypto('another-namespace')
await appB.prepareForLaunch({ receiveChallenge: () => {} })
await appB.launch(true)
await Factory.loginToApplication({
application: appB,
email: contextA.email,
password: contextA.password,
})
/** Change password on appB */
await appB.changePassword(contextA.password, newPassword)
const note = await Factory.createSyncedNote(appB)
await appB.sync.sync()
/** We expect the item in appA to be errored at this point, but we do not want it to recover */
await appA.sync.sync()
expect(appA.payloadManager.findOne(note.uuid).waitingForKey).to.equal(true)
console.warn('Expecting exceptions below as we destroy app during key recovery')
await Factory.safeDeinit(appA)
await Factory.safeDeinit(appB)
const recreatedAppA = await Factory.createApplicationWithFakeCrypto(namespace)
await recreatedAppA.prepareForLaunch({ receiveChallenge: () => {} })
await recreatedAppA.launch(true)
expect(recreatedAppA.payloadManager.findOne(note.uuid).errorDecrypting).to.equal(true)
expect(recreatedAppA.payloadManager.findOne(note.uuid).waitingForKey).to.equal(true)
await Factory.safeDeinit(recreatedAppA)
})
it('when client key params differ from server, and no matching items key exists to compare against, should perform sign in flow', async function () {
/**
* When a user changes password/email on client A, client B must update their root key to the new one.
* To do this, we can potentially avoid making a new sign in request (and creating a new session) by instead
* reading one of the undecryptable items key (which is potentially the new one that client A created). If the keyParams
* of that items key matches the servers, it means we can use those key params to compute our new local root key,
* instead of having to sign in.
*/
const unassociatedPassword = 'randfoo'
const context = await Factory.createAppContextWithFakeCrypto('some-namespace')
const application = context.application
const receiveChallenge = (challenge) => {
const isKeyRecoveryPrompt = challenge.subheading?.includes(KeyRecoveryStrings.KeyRecoveryPasswordRequired)
application.submitValuesForChallenge(challenge, [
CreateChallengeValue(challenge.prompts[0], isKeyRecoveryPrompt ? unassociatedPassword : context.password),
])
}
await application.prepareForLaunch({ receiveChallenge })
await application.launch(true)
await context.register()
const correctRootKey = await application.protocolService.getRootKey()
/**
* 1. Change our root key locally so that its keys params doesn't match the server's
* 2. Create an items key payload that is set to errorDecrypting, and which is encrypted
* with the incorrect root key, so that it cannot be used to validate the user's password
*/
const unassociatedIdentifier = 'foorand'
/** Create items key associated with a random root key */
const randomRootKey = await application.protocolService.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
)
const signInFunction = sinon.spy(application.keyRecoveryService, 'performServerSignIn')
await application.protocolService.setRootKey(randomRootKey)
const correctItemsKey = await application.protocolService.operatorManager.defaultOperator().createItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKey: {
items: [correctItemsKey.payload],
key: randomRootKey,
},
})
const resolvePromise = Promise.all([
context.awaitSignInEvent(),
context.resolveWhenKeyRecovered(correctItemsKey.uuid),
])
await application.payloadManager.emitPayload(
encrypted.copy({
errorDecrypting: true,
dirty: true,
}),
PayloadEmitSource.LocalInserted,
)
await context.sync()
await resolvePromise
expect(signInFunction.callCount).to.equal(1)
const clientRootKey = await application.protocolService.getRootKey()
expect(clientRootKey.compare(correctRootKey)).to.equal(true)
const decryptedKey = application.items.findItem(correctItemsKey.uuid)
expect(decryptedKey).to.be.ok
expect(decryptedKey.content.itemsKey).to.equal(correctItemsKey.content.itemsKey)
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it(`when encountering an items key that cannot be decrypted for which we already have a decrypted value,
it should be emitted as ignored`, async function () {
const context = await Factory.createAppContextWithFakeCrypto()
const application = context.application
await context.launch()
await context.register()
/** Create and emit errored encrypted items key payload */
const itemsKey = await application.protocolService.getSureDefaultItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [itemsKey.payload],
},
})
const newUpdated = new Date()
const errored = encrypted.copy({
content: '004:...',
errorDecrypting: true,
updated_at: newUpdated,
})
await context.receiveServerResponse({ retrievedItems: [errored.ejected()] })
/** Our current items key should not be overwritten */
const currentItemsKey = application.items.findItem(itemsKey.uuid)
expect(currentItemsKey.errorDecrypting).to.not.be.ok
expect(currentItemsKey.itemsKey).to.equal(itemsKey.itemsKey)
/** The timestamp of our current key should be updated however so we do not enter out of sync state */
expect(currentItemsKey.serverUpdatedAt.getTime()).to.equal(newUpdated.getTime())
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it(`ignored key payloads should be added to undecryptables and recovered`, async function () {
const context = await Factory.createAppContextWithFakeCrypto()
const application = context.application
await context.launch()
await context.register()
const itemsKey = await application.protocolService.getSureDefaultItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [itemsKey.payload],
},
})
const newUpdated = new Date()
const errored = encrypted.copy({
errorDecrypting: true,
updated_at: newUpdated,
})
await application.payloadManager.emitDeltaEmit({
emits: [],
ignored: [errored],
source: PayloadEmitSource.RemoteRetrieved,
})
await context.resolveWhenKeyRecovered(itemsKey.uuid)
const latestItemsKey = application.items.findItem(itemsKey.uuid)
expect(latestItemsKey.errorDecrypting).to.not.be.ok
expect(latestItemsKey.itemsKey).to.equal(itemsKey.itemsKey)
expect(latestItemsKey.serverUpdatedAt.getTime()).to.equal(newUpdated.getTime())
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it('application should prompt to recover undecryptables on launch', async function () {
const namespace = Factory.randomString()
const context = await Factory.createAppContextWithFakeCrypto(namespace)
const application = context.application
await context.launch()
await context.register()
/** Create and emit errored encrypted items key payload */
const itemsKey = await application.protocolService.getSureDefaultItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [itemsKey.payload],
},
})
context.disableKeyRecovery()
await application.payloadManager.emitDeltaEmit({
emits: [],
ignored: [
encrypted.copy({
errorDecrypting: true,
}),
],
source: PayloadEmitSource.RemoteRetrieved,
})
/** Allow enough time to persist to disk, but not enough to complete recovery wizard */
console.warn('Expecting some error below because we are destroying app in the middle of processing.')
await Factory.sleep(0.1)
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
const recreatedContext = await Factory.createAppContextWithFakeCrypto(namespace, context.email, context.password)
const recreatedApp = recreatedContext.application
const promise = recreatedContext.resolveWhenKeyRecovered(itemsKey.uuid)
await recreatedContext.launch()
await promise
await Factory.safeDeinit(recreatedApp)
})
it('when encountering an undecryptable 003 items key, should recover through recovery wizard', async function () {
const namespace = Factory.randomString()
const unassociatedPassword = 'randfoo'
const unassociatedIdentifier = 'foorand'
const context = await Factory.createAppContextWithFakeCrypto(namespace)
const application = context.application
const receiveChallenge = (challenge) => {
/** Give unassociated password when prompted */
application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], unassociatedPassword)])
}
await application.prepareForLaunch({ receiveChallenge })
await application.launch(true)
await Factory.registerOldUser({
application: application,
email: context.email,
password: context.password,
version: ProtocolVersion.V003,
})
/** Create items key associated with a random root key */
const randomRootKey = await application.protocolService.createRootKey(
unassociatedIdentifier,
unassociatedPassword,
KeyParamsOrigination.Registration,
ProtocolVersion.V003,
)
const randomItemsKey = await application.protocolService.operatorManager
.operatorForVersion(ProtocolVersion.V003)
.createItemsKey()
const encrypted = await application.protocolService.encryptSplitSingle({
usesRootKey: {
items: [randomItemsKey.payload],
key: randomRootKey,
},
})
/** Attempt decryption and insert into rotation in errored state */
const decrypted = await application.protocolService.decryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [encrypted],
},
})
/** Expect to be errored */
expect(decrypted.errorDecrypting).to.equal(true)
/** Insert into rotation */
await application.payloadManager.emitPayload(decrypted, PayloadEmitSource.LocalInserted)
/** Wait and allow recovery wizard to complete */
await Factory.sleep(0.3)
/** Should be decrypted now */
expect(application.items.findItem(encrypted.uuid).errorDecrypting).to.not.be.ok
expect(application.syncService.isOutOfSync()).to.equal(false)
await context.deinit()
})
it('when replacing root key, new root key should be set before items key are re-saved to disk', async function () {
const contextA = await Factory.createAppContextWithFakeCrypto()
await contextA.launch()
await contextA.register()
const newPassword = 'new-password'
/** Create simultaneous appB signed into same account */
const contextB = await Factory.createAppContextWithFakeCrypto(
'another-namespace',
contextA.email,
contextA.password,
)
contextB.ignoreChallenges()
await contextB.launch()
await contextB.signIn()
const appB = contextB.application
/** Change password on appB */
const result = await appB.changePassword(contextA.password, newPassword)
expect(result.error).to.not.be.ok
contextA.password = newPassword
await appB.sync.sync()
const newDefaultKey = appB.protocolService.getSureDefaultItemsKey()
const encrypted = await appB.protocolService.encryptSplitSingle({
usesRootKeyWithKeyLookup: {
items: [newDefaultKey.payload],
},
})
/** Insert foreign items key into appA, which shouldn't be able to decrypt it yet */
const appA = contextA.application
await appA.payloadManager.emitPayload(
encrypted.copy({
errorDecrypting: true,
}),
PayloadEmitSource.LocalInserted,
)
await Factory.awaitFunctionInvokation(appA.keyRecoveryService, 'handleDecryptionOfAllKeysMatchingCorrectRootKey')
/** Stored version of items key should use new root key */
const stored = (await appA.deviceInterface.getAllRawDatabasePayloads(appA.identifier)).find(
(payload) => payload.uuid === newDefaultKey.uuid,
)
const storedParams = await appA.protocolService.getKeyEmbeddedKeyParams(new EncryptedPayload(stored))
const correctStored = (await appB.deviceInterface.getAllRawDatabasePayloads(appB.identifier)).find(
(payload) => payload.uuid === newDefaultKey.uuid,
)
const correctParams = await appB.protocolService.getKeyEmbeddedKeyParams(new EncryptedPayload(correctStored))
expect(storedParams).to.eql(correctParams)
await contextA.deinit()
await contextB.deinit()
}).timeout(80000)
})

View File

@@ -0,0 +1,845 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
import * as Utils from './lib/Utils.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('keys', function () {
this.timeout(Factory.TwentySecondTimeout)
beforeEach(async function () {
localStorage.clear()
this.context = await Factory.createAppContext()
await this.context.launch()
this.application = this.context.application
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
})
afterEach(async function () {
if (!this.application.dealloced) {
await Factory.safeDeinit(this.application)
}
this.application = undefined
localStorage.clear()
})
it('should not have root key by default', async function () {
expect(await this.application.protocolService.getRootKey()).to.not.be.ok
})
it('validates content types requiring root encryption', function () {
expect(ContentTypeUsesRootKeyEncryption(ContentType.ItemsKey)).to.equal(true)
expect(ContentTypeUsesRootKeyEncryption(ContentType.EncryptedStorage)).to.equal(true)
expect(ContentTypeUsesRootKeyEncryption(ContentType.Item)).to.equal(false)
expect(ContentTypeUsesRootKeyEncryption(ContentType.Note)).to.equal(false)
})
it('generating export params with no account or passcode should produce encrypted payload', async function () {
/** Items key available by default */
const payload = Factory.createNotePayload()
const processedPayload = CreateEncryptedLocalStorageContextPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(isEncryptedPayload(processedPayload)).to.equal(true)
})
it('has root key and one items key after registering user', async function () {
await Factory.registerUserToApplication({ application: this.application })
expect(this.application.protocolService.getRootKey()).to.be.ok
expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1)
})
it('changing root key with passcode should re-wrap root key', async function () {
const email = 'foo'
const password = 'bar'
const key = await this.application.protocolService.createRootKey(email, password, KeyParamsOrigination.Registration)
await this.application.protocolService.setRootKey(key)
Factory.handlePasswordChallenges(this.application, password)
await this.application.addPasscode(password)
/** We should be able to decrypt wrapped root key with passcode */
const wrappingKeyParams = await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()
const wrappingKey = await this.application.protocolService.computeRootKey(password, wrappingKeyParams)
await this.application.protocolService.unwrapRootKey(wrappingKey).catch((error) => {
expect(error).to.not.be.ok
})
const newPassword = 'bar'
const newKey = await this.application.protocolService.createRootKey(
email,
newPassword,
KeyParamsOrigination.Registration,
)
await this.application.protocolService.setRootKey(newKey, wrappingKey)
await this.application.protocolService.unwrapRootKey(wrappingKey).catch((error) => {
expect(error).to.not.be.ok
})
})
it('items key should be encrypted with root key', async function () {
await Factory.registerUserToApplication({ application: this.application })
const itemsKey = await this.application.protocolService.getSureDefaultItemsKey()
const rootKey = await this.application.protocolService.getRootKey()
/** Encrypt items key */
const encryptedPayload = await this.application.protocolService.encryptSplitSingle({
usesRootKey: {
items: [itemsKey.payloadRepresentation()],
key: rootKey,
},
})
/** Should not have an items_key_id */
expect(encryptedPayload.items_key_id).to.not.be.ok
/** Attempt to decrypt with root key. Should succeed. */
const decryptedPayload = await this.application.protocolService.decryptSplitSingle({
usesRootKey: {
items: [encryptedPayload],
key: rootKey,
},
})
expect(decryptedPayload.errorDecrypting).to.not.be.ok
expect(decryptedPayload.content.itemsKey).to.equal(itemsKey.content.itemsKey)
})
it('should create random items key if no account and no passcode', async function () {
const itemsKeys = this.application.itemManager.getDisplayableItemsKeys()
expect(itemsKeys.length).to.equal(1)
const notePayload = Factory.createNotePayload()
const dirtied = notePayload.copy({
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
})
await this.application.payloadManager.emitPayload(dirtied, PayloadEmitSource.LocalChanged)
await this.application.sync.sync()
const rawPayloads = await this.application.diskStorageService.getAllRawPayloads()
const rawNotePayload = rawPayloads.find((r) => r.content_type === ContentType.Note)
expect(typeof rawNotePayload.content).to.equal('string')
})
it('should keep offline created items key upon registration', async function () {
expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1)
const originalItemsKey = this.application.itemManager.getDisplayableItemsKeys()[0]
await this.application.register(this.email, this.password)
expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1)
const newestItemsKey = this.application.itemManager.getDisplayableItemsKeys()[0]
expect(newestItemsKey.uuid).to.equal(originalItemsKey.uuid)
})
it('should use items key for encryption of note', async function () {
const keyToUse = await this.application.protocolService.itemsEncryption.keyToUseForItemEncryption()
expect(keyToUse.content_type).to.equal(ContentType.ItemsKey)
})
it('encrypting an item should associate an items key to it', async function () {
const note = Factory.createNotePayload()
const encryptedPayload = await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [note],
},
})
const itemsKey = this.application.protocolService.itemsKeyForPayload(encryptedPayload)
expect(itemsKey).to.be.ok
})
it('decrypt encrypted item with associated key', async function () {
const note = Factory.createNotePayload()
const title = note.content.title
const encryptedPayload = await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [note],
},
})
const itemsKey = this.application.protocolService.itemsKeyForPayload(encryptedPayload)
expect(itemsKey).to.be.ok
const decryptedPayload = await this.application.protocolService.decryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [encryptedPayload],
},
})
expect(decryptedPayload.content.title).to.equal(title)
})
it('decrypts items waiting for keys', async function () {
const notePayload = Factory.createNotePayload()
const title = notePayload.content.title
const encryptedPayload = await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [notePayload],
},
})
const itemsKey = this.application.protocolService.itemsKeyForPayload(encryptedPayload)
await this.application.itemManager.removeItemLocally(itemsKey)
const erroredPayload = await this.application.protocolService.decryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [encryptedPayload],
},
})
await this.application.itemManager.emitItemsFromPayloads([erroredPayload], PayloadEmitSource.LocalChanged)
const note = this.application.itemManager.findAnyItem(notePayload.uuid)
expect(note.errorDecrypting).to.equal(true)
expect(note.waitingForKey).to.equal(true)
const keyPayload = new DecryptedPayload(itemsKey.payload.ejected())
await this.application.itemManager.emitItemsFromPayloads([keyPayload], PayloadEmitSource.LocalChanged)
/**
* Sleeping is required to trigger asyncronous protocolService.decryptItemsWaitingForKeys,
* which occurs after keys are mapped above.
*/
await Factory.sleep(0.2)
const updatedNote = this.application.itemManager.findItem(note.uuid)
expect(updatedNote.errorDecrypting).to.not.be.ok
expect(updatedNote.waitingForKey).to.not.be.ok
expect(updatedNote.content.title).to.equal(title)
})
it('attempting to emit errored items key for which there exists a non errored master copy should ignore it', async function () {
await Factory.registerUserToApplication({ application: this.application })
const itemsKey = await this.application.protocolService.getSureDefaultItemsKey()
expect(itemsKey.errorDecrypting).to.not.be.ok
const errored = new EncryptedPayload({
...itemsKey.payload,
content: '004:...',
errorDecrypting: true,
})
const response = new ServerSyncResponse({
data: {
retrieved_items: [errored.ejected()],
},
})
await this.application.syncService.handleSuccessServerResponse({ payloadsSavedOrSaving: [] }, response)
const refreshedKey = this.application.payloadManager.findOne(itemsKey.uuid)
expect(refreshedKey.errorDecrypting).to.not.be.ok
expect(refreshedKey.content.itemsKey).to.be.ok
})
it('generating export params with logged in account should produce encrypted payload', async function () {
await Factory.registerUserToApplication({ application: this.application })
const payload = Factory.createNotePayload()
const encryptedPayload = await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
})
expect(typeof encryptedPayload.content).to.equal('string')
expect(encryptedPayload.content.substring(0, 3)).to.equal(this.application.protocolService.getLatestVersion())
})
it('When setting passcode, should encrypt items keys', async function () {
await this.application.addPasscode('foo')
const itemsKey = this.application.itemManager.getDisplayableItemsKeys()[0]
const rawPayloads = await this.application.diskStorageService.getAllRawPayloads()
const itemsKeyRawPayload = rawPayloads.find((p) => p.uuid === itemsKey.uuid)
const itemsKeyPayload = new EncryptedPayload(itemsKeyRawPayload)
expect(itemsKeyPayload.enc_item_key).to.be.ok
})
it('items key encrypted payload should contain root key params', async function () {
await this.application.addPasscode('foo')
const itemsKey = this.application.itemManager.getDisplayableItemsKeys()[0]
const rawPayloads = await this.application.diskStorageService.getAllRawPayloads()
const itemsKeyRawPayload = rawPayloads.find((p) => p.uuid === itemsKey.uuid)
const itemsKeyPayload = new EncryptedPayload(itemsKeyRawPayload)
const operator = this.application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V004)
const comps = operator.deconstructEncryptedPayloadString(itemsKeyPayload.content)
const rawAuthenticatedData = comps.authenticatedData
const authenticatedData = await operator.stringToAuthenticatedData(rawAuthenticatedData)
const rootKeyParams = await this.application.protocolService.getRootKeyParams()
expect(authenticatedData.kp).to.be.ok
expect(authenticatedData.kp).to.eql(rootKeyParams.getPortableValue())
expect(authenticatedData.kp.origination).to.equal(KeyParamsOrigination.PasscodeCreate)
})
it('correctly validates local passcode', async function () {
const passcode = 'foo'
await this.application.addPasscode('foo')
expect((await this.application.protocolService.validatePasscode('wrong')).valid).to.equal(false)
expect((await this.application.protocolService.validatePasscode(passcode)).valid).to.equal(true)
})
it('signing into 003 account should delete latest offline items key and create 003 items key', async function () {
/**
* When starting the application it will create an items key with the latest protocol version (004).
* Upon signing into an 003 account, the application should delete any neverSynced items keys,
* and create a new default items key that is the default for a given protocol version.
*/
const defaultItemsKey = await this.application.protocolService.getSureDefaultItemsKey()
const latestVersion = this.application.protocolService.getLatestVersion()
expect(defaultItemsKey.keyVersion).to.equal(latestVersion)
/** Register with 003 version */
await Factory.registerOldUser({
application: this.application,
email: this.email,
password: this.password,
version: ProtocolVersion.V003,
})
const itemsKeys = this.application.itemManager.getDisplayableItemsKeys()
expect(itemsKeys.length).to.equal(1)
const newestItemsKey = itemsKeys[0]
expect(newestItemsKey.keyVersion).to.equal(ProtocolVersion.V003)
const rootKey = await this.application.protocolService.getRootKey()
expect(newestItemsKey.itemsKey).to.equal(rootKey.masterKey)
expect(newestItemsKey.dataAuthenticationKey).to.equal(rootKey.dataAuthenticationKey)
})
it('reencrypts existing notes when logging into an 003 account', async function () {
await Factory.createManyMappedNotes(this.application, 10)
await Factory.registerOldUser({
application: this.application,
email: this.email,
password: this.password,
version: ProtocolVersion.V003,
})
expect(this.application.payloadManager.invalidPayloads.length).to.equal(0)
expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1)
expect(this.application.itemManager.getDisplayableItemsKeys()[0].dirty).to.equal(false)
/** Sign out and back in */
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true)
expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1)
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(10)
expect(this.application.payloadManager.invalidPayloads.length).to.equal(0)
})
it('When root key changes, all items keys must be re-encrypted', async function () {
const passcode = 'foo'
await this.application.addPasscode(passcode)
await Factory.createSyncedNote(this.application)
const itemsKeys = this.application.itemManager.getDisplayableItemsKeys()
expect(itemsKeys.length).to.equal(1)
const originalItemsKey = itemsKeys[0]
const originalRootKey = await this.application.protocolService.getRootKey()
/** Expect that we can decrypt raw payload with current root key */
const rawPayloads = await this.application.diskStorageService.getAllRawPayloads()
const itemsKeyRawPayload = rawPayloads.find((p) => p.uuid === originalItemsKey.uuid)
const itemsKeyPayload = new EncryptedPayload(itemsKeyRawPayload)
const decrypted = await this.application.protocolService.decryptSplitSingle({
usesRootKey: {
items: [itemsKeyPayload],
key: originalRootKey,
},
})
expect(decrypted.errorDecrypting).to.not.be.ok
expect(decrypted.content).to.eql(originalItemsKey.content)
/** Change passcode */
Factory.handlePasswordChallenges(this.application, passcode)
await this.application.changePasscode('bar')
const newRootKey = await this.application.protocolService.getRootKey()
expect(newRootKey).to.not.equal(originalRootKey)
expect(newRootKey.masterKey).to.not.equal(originalRootKey.masterKey)
/**
* Expect that originalRootKey can no longer decrypt originalItemsKey
* as items key has been re-encrypted with new root key
*/
const rawPayloads2 = await this.application.diskStorageService.getAllRawPayloads()
const itemsKeyRawPayload2 = rawPayloads2.find((p) => p.uuid === originalItemsKey.uuid)
expect(itemsKeyRawPayload2.content).to.not.equal(itemsKeyRawPayload.content)
const itemsKeyPayload2 = new EncryptedPayload(itemsKeyRawPayload2)
const decrypted2 = await this.application.protocolService.decryptSplitSingle({
usesRootKey: {
items: [itemsKeyPayload2],
key: originalRootKey,
},
})
expect(decrypted2.errorDecrypting).to.equal(true)
/** Should be able to decrypt with new root key */
const decrypted3 = await this.application.protocolService.decryptSplitSingle({
usesRootKey: {
items: [itemsKeyPayload2],
key: newRootKey,
},
})
expect(decrypted3.errorDecrypting).to.not.be.ok
})
it('changing account password should create new items key and encrypt items with that key', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const itemsKeys = this.application.itemManager.getDisplayableItemsKeys()
expect(itemsKeys.length).to.equal(1)
const defaultItemsKey = await this.application.protocolService.getSureDefaultItemsKey()
const result = await this.application.changePassword(this.password, 'foobarfoo')
expect(result.error).to.not.be.ok
expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(2)
const newDefaultItemsKey = await this.application.protocolService.getSureDefaultItemsKey()
expect(newDefaultItemsKey.uuid).to.not.equal(defaultItemsKey.uuid)
const note = await Factory.createSyncedNote(this.application)
const payload = await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [note.payload],
},
})
expect(payload.items_key_id).to.equal(newDefaultItemsKey.uuid)
})
it('changing account email should create new items key and encrypt items with that key', async function () {
const { application, email, password } = await Factory.createAndInitSimpleAppContext()
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
const itemsKeys = application.itemManager.getDisplayableItemsKeys()
expect(itemsKeys.length).to.equal(1)
const defaultItemsKey = application.protocolService.getSureDefaultItemsKey()
const newEmail = UuidGenerator.GenerateUuid()
const result = await application.changeEmail(newEmail, password)
expect(result.error).to.not.be.ok
expect(application.itemManager.getDisplayableItemsKeys().length).to.equal(2)
const newDefaultItemsKey = application.protocolService.getSureDefaultItemsKey()
expect(newDefaultItemsKey.uuid).to.not.equal(defaultItemsKey.uuid)
const note = await Factory.createSyncedNote(application)
const payload = await application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [note.payload],
},
})
expect(payload.items_key_id).to.equal(newDefaultItemsKey.uuid)
})
it('compares root keys', async function () {
const keyParams = {}
const a1 = await CreateNewRootKey({
version: ProtocolVersion.V004,
masterKey: '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE',
serverPassword: 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9',
keyParams,
})
const a2 = await CreateNewRootKey({
version: ProtocolVersion.V004,
masterKey: '2C26B46B68FFC68FF99B453C1D30413413422D706483BFA0F98A5E886266E7AE',
serverPassword: 'FCDE2B2EDBA56BF408601FB721FE9B5C338D10EE429EA04FAE5511B68FBF8FB9',
keyParams,
})
const b = await CreateNewRootKey({
version: ProtocolVersion.V004,
masterKey: '2CF24DBA5FB0A30E26E83B2AC5B9E29E1B161E5C1FA7425E73043362938B9824',
serverPassword: '486EA46224D1BB4FB680F34F7C9AD96A8F24EC88BE73EA8E5A6C65260E9CB8A7',
keyParams,
})
expect(a1.compare(a2)).to.equal(true)
expect(a2.compare(a1)).to.equal(true)
expect(a1.compare(b)).to.equal(false)
expect(b.compare(a1)).to.equal(false)
})
it('loading the keychain root key should also load its key params', async function () {
await Factory.registerUserToApplication({ application: this.application })
const rootKey = await this.application.protocolService.rootKeyEncryption.getRootKeyFromKeychain()
expect(rootKey.keyParams).to.be.ok
})
it('key params should be persisted separately and not as part of root key', async function () {
await Factory.registerUserToApplication({ application: this.application })
const rawKey = await this.application.deviceInterface.getNamespacedKeychainValue(this.application.identifier)
expect(rawKey.keyParams).to.not.be.ok
const rawKeyParams = await this.application.diskStorageService.getValue(
StorageKey.RootKeyParams,
StorageValueModes.Nonwrapped,
)
expect(rawKeyParams).to.be.ok
})
it('persisted key params should exactly equal in memory rootKey.keyParams', async function () {
await Factory.registerUserToApplication({ application: this.application })
const rootKey = await this.application.protocolService.getRootKey()
const rawKeyParams = await this.application.diskStorageService.getValue(
StorageKey.RootKeyParams,
StorageValueModes.Nonwrapped,
)
expect(rootKey.keyParams.content).to.eql(rawKeyParams)
})
it('key params should have expected values', async function () {
await Factory.registerUserToApplication({ application: this.application })
const keyParamsObject = await this.application.protocolService.getRootKeyParams()
const keyParams = keyParamsObject.content
expect(keyParams.identifier).to.be.ok
expect(keyParams.pw_nonce).to.be.ok
expect(keyParams.version).to.equal(ProtocolVersion.V004)
expect(keyParams.created).to.be.ok
expect(keyParams.origination).to.equal(KeyParamsOrigination.Registration)
expect(keyParams.email).to.not.be.ok
expect(keyParams.pw_cost).to.not.be.ok
expect(keyParams.pw_salt).to.not.be.ok
})
it('key params obtained when signing in should have created and origination', async function () {
const email = this.email
const password = this.password
await Factory.registerUserToApplication({
application: this.application,
email,
password,
})
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
await Factory.loginToApplication({
application: this.application,
email,
password,
})
const keyParamsObject = await this.application.protocolService.getRootKeyParams()
const keyParams = keyParamsObject.content
expect(keyParams.created).to.be.ok
expect(keyParams.origination).to.equal(KeyParamsOrigination.Registration)
})
it('key params for 003 account should still have origination and created', async function () {
/** origination and created are new properties since 004, but they can be added retroactively
* to previous versions. They are not essential to <= 003, but are for >= 004 */
/** Register with 003 version */
await Factory.registerOldUser({
application: this.application,
email: this.email,
password: this.password,
version: ProtocolVersion.V003,
})
const keyParamsObject = await this.application.protocolService.getRootKeyParams()
const keyParams = keyParamsObject.content
expect(keyParams.created).to.be.ok
expect(keyParams.origination).to.equal(KeyParamsOrigination.Registration)
})
it('encryption name should be dependent on key params version', async function () {
/** Register with 003 account */
await Factory.registerOldUser({
application: this.application,
email: this.email,
password: this.password,
version: ProtocolVersion.V003,
})
expect(await this.application.protocolService.getEncryptionDisplayName()).to.equal('AES-256')
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
/** Register with 004 account */
await this.application.register(this.email + 'new', this.password)
expect(await this.application.protocolService.getEncryptionDisplayName()).to.equal('XChaCha20-Poly1305')
})
it('when launching app with no keychain but data, should present account recovery challenge', async function () {
/**
* On iOS (and perhaps other platforms where keychains are not included in device backups),
* when setting up a new device from restore, the keychain is deleted, but the data persists.
* We want to make sure we're prompting the user to re-authenticate their account.
*/
const id = this.application.identifier
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
/** Simulate empty keychain */
await this.application.deviceInterface.clearRawKeychainValue()
const recreatedApp = await Factory.createApplicationWithFakeCrypto(id)
let totalChallenges = 0
const expectedChallenges = 1
const receiveChallenge = (challenge) => {
totalChallenges++
recreatedApp.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], this.password)])
}
await recreatedApp.prepareForLaunch({ receiveChallenge })
await recreatedApp.launch(true)
expect(recreatedApp.protocolService.getRootKey()).to.be.ok
expect(totalChallenges).to.equal(expectedChallenges)
await Factory.safeDeinit(recreatedApp)
})
it('errored second client should not upload its items keys', async function () {
/**
* The original source of this issue was that when changing password on client A and syncing with B,
* the newly encrypted items key retrieved on B would be included as "ignored", so its timestamps
* would not be emitted, and thus the application would be in sync. The app would then download
* the items key independently, and make duplicates erroneously.
*/
const contextA = this.context
const email = Utils.generateUuid()
const password = Utils.generateUuid()
await Factory.registerUserToApplication({
application: contextA.application,
email,
password: password,
})
const contextB = await Factory.createAppContext({ email, password })
await contextB.launch()
await contextB.signIn()
contextA.ignoreChallenges()
contextB.ignoreChallenges()
const newPassword = Utils.generateUuid()
await contextA.application.userService.changeCredentials({
currentPassword: password,
newPassword: newPassword,
origination: KeyParamsOrigination.PasswordChange,
})
await contextB.syncWithIntegrityCheck()
await contextA.syncWithIntegrityCheck()
const clientAUndecryptables = contextA.application.keyRecoveryService.getUndecryptables()
const clientBUndecryptables = contextB.application.keyRecoveryService.getUndecryptables()
expect(Object.keys(clientBUndecryptables).length).to.equal(1)
expect(Object.keys(clientAUndecryptables).length).to.equal(0)
})
describe('changing password on 003 client while signed into 004 client should', function () {
/**
* When an 004 client signs into 003 account, it creates a root key based items key.
* Then, if the 003 client changes its account password, and the 004 client
* re-authenticates, incorrect behavior (2.0.13) would be not to create a new root key based
* items key based on the new root key. The result would be that when the modified 003
* items sync to the 004 client, it can't decrypt them with its existing items key
* because its based on the old root key.
*/
it.skip('add new items key', async function () {
this.timeout(Factory.TwentySecondTimeout * 3)
let oldClient = this.application
/** Register an 003 account */
await Factory.registerOldUser({
application: oldClient,
email: this.email,
password: this.password,
version: ProtocolVersion.V003,
})
/** Sign into account from another app */
const newClient = await Factory.createAppWithRandNamespace()
await newClient.prepareForLaunch({
receiveChallenge: (challenge) => {
/** Reauth session challenge */
newClient.submitValuesForChallenge(challenge, [
CreateChallengeValue(challenge.prompts[0], this.email),
CreateChallengeValue(challenge.prompts[1], this.password),
])
},
})
await newClient.launch()
/** Change password through session manager directly instead of application,
* as not to create any items key (to simulate 003 client behavior) */
const currentRootKey = await oldClient.protocolService.computeRootKey(
this.password,
await oldClient.protocolService.getRootKeyParams(),
)
const operator = oldClient.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V003)
const newRootKey = await operator.createRootKey(this.email, this.password)
Object.defineProperty(oldClient.apiService, 'apiVersion', {
get: function () {
return '20190520'
},
})
/**
* Sign in as late as possible on new client to prevent session timeouts
*/
await newClient.signIn(this.email, this.password)
await oldClient.sessionManager.changeCredentials({
currentServerPassword: currentRootKey.serverPassword,
newRootKey,
})
/** Re-authenticate on other app; allow challenge to complete */
await newClient.sync.sync()
await Factory.sleep(1)
/** Expect a new items key to be created based on the new root key */
expect(newClient.itemManager.getDisplayableItemsKeys().length).to.equal(2)
await Factory.safeDeinit(newClient)
await Factory.safeDeinit(oldClient)
})
it('add new items key from migration if pw change already happened', async function () {
/** Register an 003 account */
await Factory.registerOldUser({
application: this.application,
email: this.email,
password: this.password,
version: ProtocolVersion.V003,
})
/** Change password through session manager directly instead of application,
* as not to create any items key (to simulate 003 client behavior) */
const currentRootKey = await this.application.protocolService.computeRootKey(
this.password,
await this.application.protocolService.getRootKeyParams(),
)
const operator = this.application.protocolService.operatorManager.operatorForVersion(ProtocolVersion.V003)
const newRootKey = await operator.createRootKey(this.email, this.password)
Object.defineProperty(this.application.apiService, 'apiVersion', {
get: function () {
return '20190520'
},
})
/** Renew session to prevent timeouts */
this.application = await Factory.signOutAndBackIn(this.application, this.email, this.password)
await this.application.sessionManager.changeCredentials({
currentServerPassword: currentRootKey.serverPassword,
newRootKey,
})
await this.application.protocolService.reencryptItemsKeys()
/** Note: this may result in a deadlock if features_service syncs and results in an error */
await this.application.sync.sync({ awaitAll: true })
/** Relaunch application and expect new items key to be created */
const identifier = this.application.identifier
/** Set to pre 2.0.15 version so migration runs */
await this.application.deviceInterface.setRawStorageValue(`${identifier}-snjs_version`, '2.0.14')
await Factory.safeDeinit(this.application)
const refreshedApp = await Factory.createApplicationWithFakeCrypto(identifier)
await Factory.initializeApplication(refreshedApp)
/** Expect a new items key to be created based on the new root key */
expect(refreshedApp.itemManager.getDisplayableItemsKeys().length).to.equal(2)
await Factory.safeDeinit(refreshedApp)
})
})
it('importing 003 account backup, then registering for account, should properly reconcile keys', async function () {
/**
* When importing a backup of an 003 account into an offline state, ItemsKeys imported
* will have an updated_at value, which tell our protocol service that this key has been
* synced before, which sort of "lies" to the protocol service because now it thinks it doesnt
* need to create a new items key because one has already been synced with the account.
* The corrective action was to do a final check in protocolService.handleDownloadFirstSyncCompletion
* to ensure there exists an items key corresponding to the user's account version.
*/
await this.application.itemManager.removeAllItemsFromMemory()
expect(this.application.protocolService.getSureDefaultItemsKey()).to.not.be.ok
const protocol003 = new SNProtocolOperator003(new SNWebCrypto())
const key = await protocol003.createItemsKey()
await this.application.itemManager.emitItemFromPayload(
key.payload.copy({
content: {
...key.payload.content,
isDefault: true,
},
dirty: true,
/** Important to indicate that the key has been synced with a server */
updated_at: Date.now(),
}),
)
const defaultKey = this.application.protocolService.getSureDefaultItemsKey()
expect(defaultKey.keyVersion).to.equal(ProtocolVersion.V003)
expect(defaultKey.uuid).to.equal(key.uuid)
await Factory.registerUserToApplication({ application: this.application })
expect(await this.application.protocolService.itemsEncryption.keyToUseForItemEncryption()).to.be.ok
})
it('having unsynced items keys should resync them upon download first sync completion', async function () {
await Factory.registerUserToApplication({ application: this.application })
const itemsKey = this.application.itemManager.getDisplayableItemsKeys()[0]
await this.application.itemManager.emitItemFromPayload(
itemsKey.payload.copy({
dirty: false,
updated_at: new Date(0),
deleted: false,
}),
)
await this.application.syncService.sync({
mode: SyncMode.DownloadFirst,
})
const updatedKey = this.application.items.findItem(itemsKey.uuid)
expect(updatedKey.neverSynced).to.equal(false)
})
it('having key while offline then signing into account with key should only have 1 default items key', async function () {
const otherClient = await Factory.createInitAppWithFakeCrypto()
/** Invert order of keys */
otherClient.itemManager.itemsKeyDisplayController.setDisplayOptions({ sortBy: 'dsc' })
/** On client A, create account and note */
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
await Factory.createSyncedNote(this.application)
const itemsKey = this.application.items.getItems(ContentType.ItemsKey)[0]
/** Create another client and sign into account */
await Factory.loginToApplication({
application: otherClient,
email: this.email,
password: this.password,
})
const defaultKeys = otherClient.protocolService.itemsEncryption.getItemsKeys().filter((key) => {
return key.isDefault
})
expect(defaultKeys.length).to.equal(1)
const rawPayloads = await otherClient.diskStorageService.getAllRawPayloads()
const notePayload = rawPayloads.find((p) => p.content_type === ContentType.Note)
expect(notePayload.items_key_id).to.equal(itemsKey.uuid)
})
})

View File

@@ -0,0 +1,316 @@
import FakeWebCrypto from './fake_web_crypto.js'
import * as Applications from './Applications.js'
import * as Utils from './Utils.js'
import { createNotePayload } from './Items.js'
UuidGenerator.SetGenerator(new FakeWebCrypto().generateUUID)
const MaximumSyncOptions = {
checkIntegrity: true,
awaitAll: true,
}
export class AppContext {
constructor({ identifier, crypto, email, password, passcode } = {}) {
if (!identifier) {
identifier = `${Math.random()}`
}
if (!email) {
email = UuidGenerator.GenerateUuid()
}
if (!password) {
password = UuidGenerator.GenerateUuid()
}
if (!passcode) {
passcode = 'mypasscode'
}
this.identifier = identifier
this.crypto = crypto
this.email = email
this.password = password
this.passcode = passcode
}
enableLogging() {
const syncService = this.application.syncService
const payloadManager = this.application.payloadManager
syncService.getServiceName = () => {
return `${this.identifier}—SyncService`
}
payloadManager.getServiceName = () => {
return `${this.identifier}-PayloadManager`
}
syncService.loggingEnabled = true
payloadManager.loggingEnabled = true
}
async initialize() {
this.application = await Applications.createApplication(
this.identifier,
undefined,
undefined,
undefined,
this.crypto || new FakeWebCrypto(),
)
}
ignoreChallenges() {
this.ignoringChallenges = true
}
resumeChallenges() {
this.ignoringChallenges = false
}
disableIntegrityAutoHeal() {
this.application.syncService.emitOutOfSyncRemotePayloads = () => {
console.warn('Integrity self-healing is disabled for this test')
}
}
disableKeyRecovery() {
this.application.keyRecoveryService.beginKeyRecovery = () => {
console.warn('Key recovery is disabled for this test')
}
}
handleChallenge = (challenge) => {
if (this.ignoringChallenges) {
this.application.challengeService.cancelChallenge(challenge)
return
}
const responses = []
const accountPassword = this.passwordToUseForAccountPasswordChallenge || this.password
for (const prompt of challenge.prompts) {
if (prompt.validation === ChallengeValidation.LocalPasscode) {
responses.push(CreateChallengeValue(prompt, this.passcode))
} else if (prompt.validation === ChallengeValidation.AccountPassword) {
responses.push(CreateChallengeValue(prompt, accountPassword))
} else if (prompt.validation === ChallengeValidation.ProtectionSessionDuration) {
responses.push(CreateChallengeValue(prompt, 0))
} else if (prompt.placeholder === 'Email') {
responses.push(CreateChallengeValue(prompt, this.email))
} else if (prompt.placeholder === 'Password') {
responses.push(CreateChallengeValue(prompt, accountPassword))
} else if (challenge.heading.includes('account password')) {
responses.push(CreateChallengeValue(prompt, accountPassword))
} else {
console.log('Unhandled challenge', challenge)
throw Error(`Unhandled custom challenge in Factory.createAppContext`)
}
}
this.application.submitValuesForChallenge(challenge, responses)
}
signIn() {
const strict = false
const ephemeral = false
const mergeLocal = true
const awaitSync = true
return this.application.signIn(this.email, this.password, strict, ephemeral, mergeLocal, awaitSync)
}
register() {
return this.application.register(this.email, this.password)
}
receiveServerResponse({ retrievedItems }) {
const response = new ServerSyncResponse({
data: {
retrieved_items: retrievedItems,
},
})
return this.application.syncService.handleSuccessServerResponse({ payloadsSavedOrSaving: [] }, response)
}
resolveWhenKeyRecovered(uuid) {
return new Promise((resolve) => {
this.application.keyRecoveryService.addEventObserver((_eventName, keys) => {
if (Uuids(keys).includes(uuid)) {
resolve()
}
})
})
}
async awaitSignInEvent() {
return new Promise((resolve) => {
this.application.userService.addEventObserver((eventName) => {
if (eventName === AccountEvent.SignedInOrRegistered) {
resolve()
}
})
})
}
async restart() {
const id = this.application.identifier
await Utils.safeDeinit(this.application)
const newApplication = await Applications.createAndInitializeApplication(id)
this.application = newApplication
return newApplication
}
syncWithIntegrityCheck() {
return this.application.sync.sync({ checkIntegrity: true, awaitAll: true })
}
awaitNextSucessfulSync() {
return new Promise((resolve) => {
const removeObserver = this.application.syncService.addEventObserver((event) => {
if (event === SyncEvent.SyncCompletedWithAllItemsUploadedAndDownloaded) {
removeObserver()
resolve()
}
})
})
}
awaitNextSyncEvent(eventName) {
return new Promise((resolve) => {
const removeObserver = this.application.syncService.addEventObserver((event, data) => {
if (event === eventName) {
removeObserver()
resolve(data)
}
})
})
}
async launch({ awaitDatabaseLoad = true, receiveChallenge } = { awaitDatabaseLoad: true }) {
await this.application.prepareForLaunch({
receiveChallenge: receiveChallenge || this.handleChallenge,
})
await this.application.launch(awaitDatabaseLoad)
}
async deinit() {
await Utils.safeDeinit(this.application)
}
async sync(options) {
await this.application.sync.sync(options || { awaitAll: true })
}
async maximumSync() {
await this.sync(MaximumSyncOptions)
}
async changePassword(newPassword) {
await this.application.changePassword(this.password, newPassword)
this.password = newPassword
}
findItem(uuid) {
return this.application.items.findItem(uuid)
}
findPayload(uuid) {
return this.application.payloadManager.findPayload(uuid)
}
get itemsKeys() {
return this.application.items.getDisplayableItemsKeys()
}
disableSyncingOfItems(uuids) {
const originalImpl = this.application.items.getDirtyItems
this.application.items.getDirtyItems = function () {
const result = originalImpl.apply(this)
return result.filter((i) => !uuids.includes(i.uuid))
}
}
disableKeyRecoveryServerSignIn() {
this.application.keyRecoveryService.performServerSignIn = () => {
console.warn('application.keyRecoveryService.performServerSignIn has been stubbed with an empty implementation')
}
}
preventKeyRecoveryOfKeys(ids) {
const originalImpl = this.application.keyRecoveryService.handleUndecryptableItemsKeys
this.application.keyRecoveryService.handleUndecryptableItemsKeys = function (keys) {
const filtered = keys.filter((k) => !ids.includes(k.uuid))
originalImpl.apply(this, [filtered])
}
}
respondToAccountPasswordChallengeWith(password) {
this.passwordToUseForAccountPasswordChallenge = password
}
spyOnChangedItems(callback) {
this.application.items.addObserver(ContentType.Any, ({ changed, unerrored }) => {
callback([...changed, ...unerrored])
})
}
async createSyncedNote(title, text) {
const payload = createNotePayload(title, text)
const item = await this.application.items.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
await this.application.items.setItemDirty(item)
await this.application.syncService.sync(MaximumSyncOptions)
const note = this.application.items.findItem(payload.uuid)
return note
}
async deleteItemAndSync(item) {
await this.application.mutator.deleteItem(item)
}
async changeNoteTitle(note, title) {
return this.application.items.changeNote(note, (mutator) => {
mutator.title = title
})
}
async changeNoteTitleAndSync(note, title) {
await this.changeNoteTitle(note, title)
await this.sync()
return this.findItem(note.uuid)
}
findNoteByTitle(title) {
return this.application.items.getDisplayableNotes().find((note) => note.title === title)
}
get noteCount() {
return this.application.items.getDisplayableNotes().length
}
async createConflictedNotes(otherContext) {
const note = await this.createSyncedNote()
await otherContext.sync()
await this.changeNoteTitleAndSync(note, 'title-1')
await otherContext.changeNoteTitleAndSync(note, 'title-2')
await this.sync()
return {
original: note,
conflict: this.findNoteByTitle('title-2'),
}
}
}

View File

@@ -0,0 +1,71 @@
import WebDeviceInterface from './web_device_interface.js'
import FakeWebCrypto from './fake_web_crypto.js'
import * as Defaults from './Defaults.js'
export function createApplicationWithOptions({ identifier, environment, platform, host, crypto, device }) {
if (!device) {
device = new WebDeviceInterface()
device.environment = environment
}
return new SNApplication({
environment: environment || Environment.Web,
platform: platform || Platform.MacWeb,
deviceInterface: device,
crypto: crypto || new FakeWebCrypto(),
alertService: {
confirm: async () => true,
alert: async () => {},
blockingDialog: () => () => {},
},
identifier: identifier || `${Math.random()}`,
defaultHost: host || Defaults.getDefaultHost(),
appVersion: Defaults.getAppVersion(),
webSocketUrl: Defaults.getDefaultWebSocketUrl(),
})
}
export function createApplication(identifier, environment, platform, host, crypto) {
return createApplicationWithOptions({ identifier, environment, platform, host, crypto })
}
export function createApplicationWithFakeCrypto(identifier, environment, platform, host) {
return createApplication(identifier, environment, platform, host, new FakeWebCrypto())
}
export function createApplicationWithRealCrypto(identifier, environment, platform, host) {
return createApplication(identifier, environment, platform, host, new SNWebCrypto())
}
export async function createAppWithRandNamespace(environment, platform) {
const namespace = Math.random().toString(36).substring(2, 15)
return createApplication(namespace, environment, platform)
}
export async function createInitAppWithFakeCrypto(environment, platform) {
const namespace = Math.random().toString(36).substring(2, 15)
return createAndInitializeApplication(namespace, environment, platform, undefined, new FakeWebCrypto())
}
export async function createInitAppWithRealCrypto(environment, platform) {
const namespace = Math.random().toString(36).substring(2, 15)
return createAndInitializeApplication(namespace, environment, platform, undefined, new SNWebCrypto())
}
export async function createAndInitializeApplication(namespace, environment, platform, host, crypto) {
const application = createApplication(namespace, environment, platform, host, crypto)
await initializeApplication(application)
return application
}
export async function initializeApplication(application) {
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
console.warn('Factory received potentially unhandled challenge', challenge)
if (challenge.reason !== ChallengeReason.Custom) {
throw Error("Factory application shouldn't have challenges")
}
},
})
await application.launch(true)
}

View File

@@ -0,0 +1,15 @@
export function getDefaultHost() {
return 'http://localhost:3123'
}
export function getDefaultMockedEventServiceUrl() {
return 'http://localhost:3124'
}
export function getDefaultWebSocketUrl() {
return undefined
}
export function getAppVersion() {
return '1.2.3'
}

View File

@@ -0,0 +1,72 @@
import * as Utils from './Utils.js'
const MaximumSyncOptions = {
checkIntegrity: true,
awaitAll: true,
}
export function createItemParams(contentType) {
const params = {
uuid: Utils.generateUuid(),
content_type: contentType,
content: {
title: 'hello',
text: 'world',
},
}
return params
}
export function createNoteParams({ title, text, dirty = true } = {}) {
const params = {
uuid: Utils.generateUuid(),
content_type: ContentType.Note,
dirty: dirty,
dirtyIndex: dirty ? getIncrementedDirtyIndex() : undefined,
content: FillItemContent({
title: title || 'hello',
text: text || 'world',
}),
}
return params
}
export function createTagParams({ title, dirty = true, uuid = undefined } = {}) {
const params = {
uuid: uuid || Utils.generateUuid(),
content_type: ContentType.Tag,
dirty: dirty,
dirtyIndex: dirty ? getIncrementedDirtyIndex() : undefined,
content: FillItemContent({
title: title || 'thoughts',
}),
}
return params
}
export function createRelatedNoteTagPairPayload({ noteTitle, noteText, tagTitle, dirty = true } = {}) {
const noteParams = createNoteParams({
title: noteTitle,
text: noteText,
dirty,
})
const tagParams = createTagParams({ title: tagTitle, dirty })
tagParams.content.references = [
{
uuid: noteParams.uuid,
content_type: noteParams.content_type,
},
]
noteParams.content.references = []
return [new DecryptedPayload(noteParams), new DecryptedPayload(tagParams)]
}
export async function createSyncedNoteWithTag(application) {
const payloads = createRelatedNoteTagPairPayload()
await application.itemManager.emitItemsFromPayloads(payloads)
return application.sync.sync(MaximumSyncOptions)
}
export function createNotePayload(title, text = undefined, dirty = true) {
return new DecryptedPayload(createNoteParams({ title, text, dirty }))
}

View File

@@ -0,0 +1,34 @@
import FakeWebCrypto from './fake_web_crypto.js'
export async function safeDeinit(application) {
if (application.dealloced) {
console.warn(
'Attempting to deinit already deinited application. Check the test case to find where you are double deiniting.',
)
return
}
await application.diskStorageService.awaitPersist()
/** Limit waiting to 1s */
await Promise.race([sleep(1), application.syncService?.awaitCurrentSyncs()])
await application.prepareForDeinit()
application.deinit(DeinitMode.Soft, DeinitSource.SignOut)
}
export async function sleep(seconds) {
console.warn(`Test sleeping for ${seconds}s`)
return new Promise((resolve, reject) => {
setTimeout(function () {
resolve()
}, seconds * 1000)
})
}
export function generateUuid() {
const crypto = new FakeWebCrypto()
return crypto.generateUUID()
}

View File

@@ -0,0 +1,492 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import FakeWebCrypto from './fake_web_crypto.js'
import { AppContext } from './AppContext.js'
import * as Applications from './Applications.js'
import * as Defaults from './Defaults.js'
import * as Utils from './Utils.js'
import { createItemParams, createNoteParams, createTagParams } from './Items.js'
export const TenSecondTimeout = 10_000
export const TwentySecondTimeout = 20_000
export const ThirtySecondTimeout = 30_000
export const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
export async function createAndInitSimpleAppContext(
{ registerUser, environment } = {
registerUser: false,
environment: Environment.Web,
},
) {
const application = await createInitAppWithFakeCrypto(environment)
const email = UuidGenerator.GenerateUuid()
const password = UuidGenerator.GenerateUuid()
const newPassword = randomString()
if (registerUser) {
await registerUserToApplication({
application,
email,
password,
})
}
return {
application,
email,
password,
newPassword,
}
}
export async function createAppContextWithFakeCrypto(identifier, email, password) {
return createAppContext({ identifier, crypto: new FakeWebCrypto(), email, password })
}
export async function createAppContextWithRealCrypto(identifier) {
return createAppContext({ identifier, crypto: new SNWebCrypto() })
}
export async function createAppContext({ identifier, crypto, email, password } = {}) {
const context = new AppContext({ identifier, crypto, email, password })
await context.initialize()
return context
}
export function disableIntegrityAutoHeal(application) {
application.syncService.emitOutOfSyncRemotePayloads = () => {
console.warn('Integrity self-healing is disabled for this test')
}
}
export async function safeDeinit(application) {
return Utils.safeDeinit(application)
}
export function getDefaultHost() {
return Defaults.getDefaultHost()
}
export async function publishMockedEvent(eventType, eventPayload) {
await fetch(`${Defaults.getDefaultMockedEventServiceUrl()}/events`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
eventType,
eventPayload,
}),
})
}
export function createApplicationWithFakeCrypto(identifier, environment, platform, host) {
return Applications.createApplicationWithFakeCrypto(identifier, environment, platform, host)
}
export function createApplicationWithRealCrypto(identifier, environment, platform, host) {
return Applications.createApplicationWithRealCrypto(identifier, environment, platform, host)
}
export async function createAppWithRandNamespace(environment, platform) {
return Applications.createAppWithRandNamespace(environment, platform)
}
export async function createInitAppWithFakeCrypto(environment, platform) {
return Applications.createInitAppWithFakeCrypto(environment, platform)
}
export async function createInitAppWithFakeCryptoWithOptions({ environment, platform, identifier }) {
const application = Applications.createApplicationWithOptions({ identifier, environment, platform })
await Applications.initializeApplication(application)
return application
}
export async function createInitAppWithRealCrypto(environment, platform) {
return Applications.createInitAppWithRealCrypto(environment, platform)
}
export async function createAndInitializeApplication(namespace, environment, platform, host, crypto) {
return Applications.createAndInitializeApplication(namespace, environment, platform, host, crypto)
}
export async function initializeApplication(application) {
return Applications.initializeApplication(application)
}
export function registerUserToApplication({ application, email, password, ephemeral, mergeLocal = true }) {
if (!email) email = Utils.generateUuid()
if (!password) password = Utils.generateUuid()
return application.register(email, password, ephemeral, mergeLocal)
}
export async function setOldVersionPasscode({ application, passcode, version }) {
const identifier = await application.protocolService.crypto.generateUUID()
const operator = application.protocolService.operatorManager.operatorForVersion(version)
const key = await operator.createRootKey(identifier, passcode, KeyParamsOrigination.PasscodeCreate)
await application.protocolService.setNewRootKeyWrapper(key)
await application.userService.rewriteItemsKeys()
await application.syncService.sync(syncOptions)
}
/**
* Using application.register will always use latest version of protocol.
* To use older version, use this method.
*/
export async function registerOldUser({ application, email, password, version }) {
if (!email) email = Utils.generateUuid()
if (!password) password = Utils.generateUuid()
const operator = application.protocolService.operatorManager.operatorForVersion(version)
const accountKey = await operator.createRootKey(email, password, KeyParamsOrigination.Registration)
const response = await application.userApiService.register(email, accountKey.serverPassword, accountKey.keyParams)
/** Mark all existing items as dirty. */
await application.itemManager.changeItems(application.itemManager.items, (m) => {
m.dirty = true
})
await application.sessionManager.handleSuccessAuthResponse(response, accountKey)
application.notifyEvent(ApplicationEvent.SignedIn)
await application.syncService.sync({
mode: SyncMode.DownloadFirst,
...syncOptions,
})
await application.protocolService.decryptErroredPayloads()
}
export function createStorageItemPayload(contentType) {
return new DecryptedPayload(createItemParams(contentType))
}
export function createNotePayload(title, text = undefined, dirty = true) {
return new DecryptedPayload(createNoteParams({ title, text, dirty }))
}
export function createNote(title, text = undefined, dirty = true) {
return new SNNote(new DecryptedPayload(createNoteParams({ title, text, dirty })))
}
export function createStorageItemTagPayload(tagParams = {}) {
return new DecryptedPayload(createTagParams(tagParams))
}
export function itemToStoragePayload(item) {
return new DecryptedPayload(item)
}
export function createMappedNote(application, title, text, dirty = true) {
const payload = createNotePayload(title, text, dirty)
return application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
}
export async function createMappedTag(application, tagParams = {}) {
const payload = createStorageItemTagPayload(tagParams)
return application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
}
export async function createSyncedNote(application, title, text) {
const payload = createNotePayload(title, text)
const item = await application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
await application.itemManager.setItemDirty(item)
await application.syncService.sync(syncOptions)
const note = application.items.findItem(payload.uuid)
return note
}
export async function getStoragePayloadsOfType(application, type) {
const rawPayloads = await application.diskStorageService.getAllRawPayloads()
return rawPayloads
.filter((rp) => rp.content_type === type)
.map((rp) => {
return new CreatePayload(rp)
})
}
export async function createManyMappedNotes(application, count) {
const createdNotes = []
for (let i = 0; i < count; i++) {
const note = await createMappedNote(application)
await application.itemManager.setItemDirty(note)
createdNotes.push(note)
}
return createdNotes
}
export async function loginToApplication({
application,
email,
password,
ephemeral,
strict = false,
mergeLocal = true,
awaitSync = true,
}) {
return application.signIn(email, password, strict, ephemeral, mergeLocal, awaitSync)
}
export async function awaitFunctionInvokation(object, functionName) {
return new Promise((resolve) => {
const original = object[functionName]
object[functionName] = async function () {
const result = original.apply(this, arguments)
resolve(result)
return result
}
})
}
/**
* Signing out of an application deinits it.
* A new one must be created.
*/
export async function signOutApplicationAndReturnNew(application) {
const isRealCrypto = application.crypto instanceof SNWebCrypto
await application.user.signOut()
if (isRealCrypto) {
return createInitAppWithRealCrypto()
} else {
return createInitAppWithFakeCrypto()
}
}
export async function signOutAndBackIn(application, email, password) {
const isRealCrypto = application.crypto instanceof SNWebCrypto
await application.user.signOut()
const newApplication = isRealCrypto ? await createInitAppWithRealCrypto() : await createInitAppWithFakeCrypto()
await this.loginToApplication({
application: newApplication,
email,
password,
})
return newApplication
}
export async function restartApplication(application) {
const id = application.identifier
await safeDeinit(application)
const newApplication = await createAndInitializeApplication(id)
return newApplication
}
export async function storagePayloadCount(application) {
const payloads = await application.diskStorageService.getAllRawPayloads()
return payloads.length
}
/**
* The number of seconds between changes before a server creates a new revision.
* Controlled via docker/syncing-server-js.env
*/
export const ServerRevisionFrequency = 1.1
export function yesterday() {
return new Date(new Date().setDate(new Date().getDate() - 1))
}
export function dateToMicroseconds(date) {
return date.getTime() * 1_000
}
export function tomorrow() {
return new Date(new Date().setDate(new Date().getDate() + 1))
}
export async function sleep(seconds) {
return Utils.sleep(seconds)
}
export function shuffleArray(a) {
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
export function randomString(length = 10) {
let result = ''
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const charactersLength = characters.length
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength))
}
return result
}
export function generateUuidish() {
return this.randomString(32)
}
export function randomArrayValue(array) {
return array[Math.floor(Math.random() * array.length)]
}
export async function expectThrowsAsync(method, errorMessage) {
let error = null
try {
await method()
} catch (err) {
error = err
}
const expect = chai.expect
expect(error).to.be.an('Error')
if (errorMessage) {
expect(error.message)
.to.be.a('string')
.and.satisfy((msg) => msg.startsWith(errorMessage))
}
}
export function ignoreChallenges(application) {
application.setLaunchCallback({
receiveChallenge() {
/** no-op */
},
})
}
export function handlePasswordChallenges(application, password) {
application.setLaunchCallback({
receiveChallenge: (challenge) => {
const values = challenge.prompts.map((prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.ProtectionSessionDuration
? UnprotectedAccessSecondsDuration.OneMinute
: password,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
}
export async function createTags(application, hierarchy, parent = undefined, resultAccumulator = undefined) {
const result = resultAccumulator || {}
const promises = Object.entries(hierarchy).map(async ([key, value]) => {
let tag = await application.mutator.findOrCreateTag(key)
result[key] = tag
if (parent) {
await application.mutator.setTagParent(parent, tag)
}
if (value === true) {
return
}
await createTags(application, value, tag, result)
})
await Promise.all(promises)
return result
}
export function pinNote(application, note) {
return application.mutator.changeItem(note, (mutator) => {
mutator.pinned = true
})
}
export async function insertItemWithOverride(application, contentType, content, needsSync = false, errorDecrypting) {
const item = await application.itemManager.createItem(contentType, content, needsSync)
if (errorDecrypting) {
const encrypted = new EncryptedPayload({
...item.payload.ejected(),
content: '004:...',
errorDecrypting,
})
await application.itemManager.emitItemFromPayload(encrypted)
} else {
const decrypted = new DecryptedPayload({
...item.payload.ejected(),
})
await application.itemManager.emitItemFromPayload(decrypted)
}
return application.itemManager.findAnyItem(item.uuid)
}
export async function alternateUuidForItem(application, uuid) {
const item = application.itemManager.findItem(uuid)
const payload = new DecryptedPayload(item)
const results = await PayloadsByAlternatingUuid(payload, application.payloadManager.getMasterCollection())
await application.payloadManager.emitPayloads(results, PayloadEmitSource.LocalChanged)
await application.syncService.persistPayloads(results)
return application.itemManager.findItem(results[0].uuid)
}
export async function markDirtyAndSyncItem(application, itemToLookupUuidFor) {
const item = application.itemManager.findItem(itemToLookupUuidFor.uuid)
if (!item) {
throw Error('Attempting to save non-inserted item')
}
if (!item.dirty) {
await application.itemManager.changeItem(item, undefined, MutationType.NoUpdateUserTimestamps)
}
await application.sync.sync()
}
export async function changePayloadTimeStampAndSync(application, payload, timestamp, contentOverride, syncOptions) {
await changePayloadTimeStamp(application, payload, timestamp, contentOverride)
await application.sync.sync(syncOptions)
return application.itemManager.findAnyItem(payload.uuid)
}
export async function changePayloadTimeStamp(application, payload, timestamp, contentOverride) {
payload = application.payloadManager.collection.find(payload.uuid)
const changedPayload = new DecryptedPayload({
...payload,
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
content: {
...payload.content,
...contentOverride,
},
updated_at_timestamp: timestamp,
})
await application.itemManager.emitItemFromPayload(changedPayload)
return application.itemManager.findAnyItem(payload.uuid)
}
export async function changePayloadUpdatedAt(application, payload, updatedAt) {
const latestPayload = application.payloadManager.collection.find(payload.uuid)
const changedPayload = new DecryptedPayload({
...latestPayload,
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
updated_at: updatedAt,
})
await application.itemManager.emitItemFromPayload(changedPayload)
return application.itemManager.findAnyItem(payload.uuid)
}
export async function changePayloadTimeStampDeleteAndSync(application, payload, timestamp, syncOptions) {
payload = application.payloadManager.collection.find(payload.uuid)
const changedPayload = new DeletedPayload({
...payload,
content: undefined,
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
deleted: true,
updated_at_timestamp: timestamp,
})
await application.itemManager.emitItemFromPayload(changedPayload)
await application.sync.sync(syncOptions)
}

View File

@@ -0,0 +1,138 @@
export default class FakeWebCrypto {
constructor() {}
deinit() {}
initialize() {
return
}
randomString(len) {
const charSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let randomString = ''
for (let i = 0; i < len; i++) {
const randomPoz = Math.floor(Math.random() * charSet.length)
randomString += charSet.substring(randomPoz, randomPoz + 1)
}
return randomString
}
generateUUIDSync = () => {
const globalScope = getGlobalScope()
const crypto = globalScope.crypto || globalScope.msCrypto
if (crypto) {
const buf = new Uint32Array(4)
crypto.getRandomValues(buf)
let idx = -1
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
idx++
const r = (buf[idx >> 3] >> ((idx % 8) * 4)) & 15
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
} else {
let d = new Date().getTime()
if (globalScope.performance && typeof globalScope.performance.now === 'function') {
d += performance.now() // use high-precision timer if available
}
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = (d + Math.random() * 16) % 16 | 0
d = Math.floor(d / 16)
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
return uuid
}
}
generateUUID = () => {
return this.generateUUIDSync()
}
timingSafeEqual(a, b) {
return a === b
}
base64Encode(text) {
return btoa(text)
}
base64URLEncode(text) {
return this.base64Encode(text).replace(/\+/g, '-').replace(/\//g, '_').replace(/\=+$/, '')
}
base64Decode(base64String) {
return atob(base64String)
}
async pbkdf2(password, salt, iterations, length) {
return btoa(password + salt + iterations)
}
generateRandomKey(bits) {
const length = bits / 8
return this.randomString(length)
}
async aes256CbcEncrypt(plaintext, iv, key) {
const data = {
plaintext,
iv,
key,
}
return btoa(JSON.stringify(data))
}
async aes256CbcDecrypt(ciphertext, iv, key) {
const data = JSON.parse(atob(ciphertext))
if (data.key !== key || data.iv !== iv) {
return undefined
}
return data.plaintext
}
async hmac256(message, key) {
return btoa(message + key)
}
async sha256(text) {
return new SNWebCrypto().sha256(text)
}
async hmac1(message, key) {
return btoa(message + key)
}
async unsafeSha1(text) {
return btoa(text)
}
argon2(password, salt, iterations, bytes, length) {
return btoa(password)
}
xchacha20Encrypt(plaintext, nonce, key, assocData) {
const data = {
plaintext,
nonce,
key,
assocData,
}
return btoa(JSON.stringify(data))
}
xchacha20Decrypt(ciphertext, nonce, key, assocData) {
const data = JSON.parse(atob(ciphertext))
if (data.key !== key || data.nonce !== nonce || data.assocData !== assocData) {
return undefined
}
return data.plaintext
}
generateOtpSecret() {
return 'WQVV2GFBRQWU3UQZWQFZC37PSNRXKTA6'
}
totpToken(secret, timestamp, tokenLength, step) {
return '123456'
}
}

View File

@@ -0,0 +1,157 @@
/* eslint-disable no-undef */
const KEYCHAIN_STORAGE_KEY = 'keychain'
export default class WebDeviceInterface {
environment = Environment.Web
async getRawStorageValue(key) {
return localStorage.getItem(key)
}
async getJsonParsedRawStorageValue(key) {
const value = await this.getRawStorageValue(key)
if (isNullOrUndefined(value)) {
return undefined
}
try {
return JSON.parse(value)
} catch (e) {
return value
}
}
async getAllRawStorageKeyValues() {
const results = []
for (const key of Object.keys(localStorage)) {
results.push({
key: key,
value: localStorage[key],
})
}
return results
}
async setRawStorageValue(key, value) {
localStorage.setItem(key, value)
}
async removeRawStorageValue(key) {
localStorage.removeItem(key)
}
async removeAllRawStorageValues() {
localStorage.clear()
}
async openDatabase(_identifier) {
return {}
}
_getDatabaseKeyPrefix(identifier) {
if (identifier) {
return `${identifier}-item-`
} else {
return 'item-'
}
}
_keyForPayloadId(id, identifier) {
return `${this._getDatabaseKeyPrefix(identifier)}${id}`
}
async getAllRawDatabasePayloads(identifier) {
const models = []
for (const key in localStorage) {
if (key.startsWith(this._getDatabaseKeyPrefix(identifier))) {
models.push(JSON.parse(localStorage[key]))
}
}
return models
}
async saveRawDatabasePayload(payload, identifier) {
localStorage.setItem(this._keyForPayloadId(payload.uuid, identifier), JSON.stringify(payload))
}
async saveRawDatabasePayloads(payloads, identifier) {
for (const payload of payloads) {
await this.saveRawDatabasePayload(payload, identifier)
}
}
async removeRawDatabasePayloadWithId(id, identifier) {
localStorage.removeItem(this._keyForPayloadId(id, identifier))
}
async removeAllRawDatabasePayloads(identifier) {
for (const key in localStorage) {
if (key.startsWith(this._getDatabaseKeyPrefix(identifier))) {
delete localStorage[key]
}
}
}
/** @keychain */
async getNamespacedKeychainValue(identifier) {
const keychain = await this.getRawKeychainValue(identifier)
if (!keychain) {
return
}
return keychain[identifier]
}
async setNamespacedKeychainValue(value, identifier) {
let keychain = await this.getRawKeychainValue()
if (!keychain) {
keychain = {}
}
localStorage.setItem(
KEYCHAIN_STORAGE_KEY,
JSON.stringify({
...keychain,
[identifier]: value,
}),
)
}
async clearNamespacedKeychainValue(identifier) {
const keychain = await this.getRawKeychainValue()
if (!keychain) {
return
}
delete keychain[identifier]
localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(keychain))
}
/** Allows unit tests to set legacy keychain structure as it was <= 003 */
// eslint-disable-next-line camelcase
async setLegacyRawKeychainValue(value) {
localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(value))
}
async getRawKeychainValue() {
const keychain = localStorage.getItem(KEYCHAIN_STORAGE_KEY)
return JSON.parse(keychain)
}
async clearRawKeychainValue() {
localStorage.removeItem(KEYCHAIN_STORAGE_KEY)
}
performSoftReset() {
}
performHardReset() {
}
isDeviceDestroyed() {
return false
}
deinit() {
}
}

View File

@@ -0,0 +1,32 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
/**
* Simple empty test page to create and deinit empty application
* Then check browser Memory tool to make sure there are no leaks.
*/
describe('memory', function () {
before(async function () {
localStorage.clear()
})
after(async function () {
localStorage.clear()
})
beforeEach(async function () {
this.application = await Factory.createInitAppWithFakeCrypto()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
this.application = null
})
it('passes', async function () {
expect(true).to.equal(true)
})
})

View File

@@ -0,0 +1,68 @@
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
const createApp = async () => Factory.createInitAppWithFakeCrypto(Environment.Web, Platform.MacWeb)
const accountPassword = 'password'
const registerApp = async (snApp) => {
const email = UuidGenerator.GenerateUuid()
const password = accountPassword
const ephemeral = false
const mergeLocal = true
await snApp.register(email, password, ephemeral, mergeLocal)
return snApp
}
describe('mfa service', () => {
it('generates 160 bit base32-encoded mfa secret', async () => {
const RFC4648 = /[ABCDEFGHIJKLMNOPQRSTUVWXYZ234567]/g
const snApp = await createApp()
const secret = await snApp.generateMfaSecret()
expect(secret).to.have.lengthOf(32)
expect(secret.replace(RFC4648, '')).to.have.lengthOf(0)
Factory.safeDeinit(snApp)
})
it('activates mfa, checks if enabled, deactivates mfa', async () => {
const snApp = await createApp().then(registerApp)
Factory.handlePasswordChallenges(snApp, accountPassword)
expect(await snApp.isMfaActivated()).to.equal(false)
const secret = await snApp.generateMfaSecret()
const token = await snApp.getOtpToken(secret)
await snApp.enableMfa(secret, token)
expect(await snApp.isMfaActivated()).to.equal(true)
await snApp.disableMfa()
expect(await snApp.isMfaActivated()).to.equal(false)
Factory.safeDeinit(snApp)
}).timeout(Factory.TenSecondTimeout)
it('prompts for account password when disabling mfa', async () => {
const snApp = await createApp().then(registerApp)
Factory.handlePasswordChallenges(snApp, accountPassword)
const secret = await snApp.generateMfaSecret()
const token = await snApp.getOtpToken(secret)
sinon.spy(snApp.challengeService, 'sendChallenge')
await snApp.enableMfa(secret, token)
await snApp.disableMfa()
const spyCall = snApp.challengeService.sendChallenge.getCall(0)
const challenge = spyCall.firstArg
expect(challenge.prompts).to.have.lengthOf(2)
expect(challenge.prompts[0].validation).to.equal(ChallengeValidation.AccountPassword)
Factory.safeDeinit(snApp)
}).timeout(Factory.TenSecondTimeout)
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,584 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
import FakeWebCrypto from '../lib/fake_web_crypto.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('2020-01-15 web migration', () => {
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
})
/**
* This test will pass but sync afterwards will not be successful
* as we are using a random value for the legacy session token
*/
it('2020-01-15 migration with passcode and account', async function () {
const application = await Factory.createAppWithRandNamespace()
/** Create legacy migrations value so that base migration detects old app */
await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything']))
const operator003 = new SNProtocolOperator003(new FakeWebCrypto())
const identifier = 'foo'
const passcode = 'bar'
/** Create old version passcode parameters */
const passcodeKey = await operator003.createRootKey(identifier, passcode)
await application.deviceInterface.setRawStorageValue(
'offlineParams',
JSON.stringify(passcodeKey.keyParams.getPortableValue()),
)
/** Create arbitrary storage values and make sure they're migrated */
const arbitraryValues = {
foo: 'bar',
zar: 'tar',
har: 'car',
}
for (const key of Object.keys(arbitraryValues)) {
await application.deviceInterface.setRawStorageValue(key, arbitraryValues[key])
}
/** Create old version account parameters */
const password = 'tar'
const accountKey = await operator003.createRootKey(identifier, password)
/** Create legacy storage and encrypt it with passcode */
const embeddedStorage = {
mk: accountKey.masterKey,
ak: accountKey.dataAuthenticationKey,
pw: accountKey.serverPassword,
jwt: 'anything',
/** Legacy versions would store json strings inside of embedded storage */
auth_params: JSON.stringify(accountKey.keyParams.getPortableValue()),
}
const storagePayload = new DecryptedPayload({
uuid: await operator003.crypto.generateUUID(),
content_type: ContentType.EncryptedStorage,
content: {
storage: embeddedStorage,
},
})
const encryptionParams = await operator003.generateEncryptedParametersAsync(storagePayload, passcodeKey)
const persistPayload = new EncryptedPayload({ ...storagePayload, ...encryptionParams })
await application.deviceInterface.setRawStorageValue('encryptedStorage', JSON.stringify(persistPayload))
/** Create encrypted item and store it in db */
const notePayload = Factory.createNotePayload()
const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, accountKey)
const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams })
await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier)
/** Run migration */
await application.prepareForLaunch({
receiveChallenge: async (challenge) => {
application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], passcode)])
},
})
await application.launch(true)
expect(application.sessionManager.online()).to.equal(true)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper)
/** Should be decrypted */
const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default)
const valueStore = application.diskStorageService.values[storageMode]
expect(valueStore.content_type).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('offlineParams')).to.not.be.ok
const keyParams = await application.diskStorageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped)
expect(typeof keyParams).to.equal('object')
/** Embedded value should match */
const migratedKeyParams = await application.diskStorageService.getValue(
StorageKey.RootKeyParams,
StorageValueModes.Nonwrapped,
)
expect(migratedKeyParams).to.eql(JSON.parse(embeddedStorage.auth_params))
const rootKey = await application.protocolService.getRootKey()
expect(rootKey.masterKey).to.equal(accountKey.masterKey)
expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey)
/** Application should not retain server password from legacy versions */
expect(rootKey.serverPassword).to.not.be.ok
expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper)
/** Expect note is decrypted */
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
const retrievedNote = application.itemManager.getDisplayableNotes()[0]
expect(retrievedNote.uuid).to.equal(notePayload.uuid)
expect(retrievedNote.content.text).to.equal(notePayload.content.text)
/** Ensure arbitrary values have been migrated */
for (const key of Object.keys(arbitraryValues)) {
const value = await application.diskStorageService.getValue(key)
expect(arbitraryValues[key]).to.equal(value)
}
console.warn('Expecting exception due to deiniting application while trying to renew session')
await Factory.safeDeinit(application)
}).timeout(15000)
it('2020-01-15 migration with passcode only', async function () {
const application = await Factory.createAppWithRandNamespace()
/** Create legacy migrations value so that base migration detects old app */
await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything']))
const operator003 = new SNProtocolOperator003(new FakeWebCrypto())
const identifier = 'foo'
const passcode = 'bar'
/** Create old version passcode parameters */
const passcodeKey = await operator003.createRootKey(identifier, passcode)
await application.deviceInterface.setRawStorageValue(
'offlineParams',
JSON.stringify(passcodeKey.keyParams.getPortableValue()),
)
/** Create arbitrary storage values and make sure they're migrated */
const arbitraryValues = {
foo: 'bar',
zar: 'tar',
har: 'car',
}
for (const key of Object.keys(arbitraryValues)) {
await application.deviceInterface.setRawStorageValue(key, arbitraryValues[key])
}
const embeddedStorage = {
...arbitraryValues,
}
const storagePayload = new DecryptedPayload({
uuid: await operator003.crypto.generateUUID(),
content: {
storage: embeddedStorage,
},
content_type: ContentType.EncryptedStorage,
})
const encryptionParams = await operator003.generateEncryptedParametersAsync(storagePayload, passcodeKey)
const persistPayload = new EncryptedPayload({ ...storagePayload, ...encryptionParams })
await application.deviceInterface.setRawStorageValue('encryptedStorage', JSON.stringify(persistPayload))
/** Create encrypted item and store it in db */
const notePayload = Factory.createNotePayload()
const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, passcodeKey)
const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams })
await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier)
await application.prepareForLaunch({
receiveChallenge: async (challenge) => {
application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], passcode)])
},
})
await application.launch(true)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly)
/** Should be decrypted */
const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default)
const valueStore = application.diskStorageService.values[storageMode]
expect(valueStore.content_type).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('offlineParams')).to.not.be.ok
/** Embedded value should match */
const migratedKeyParams = await application.diskStorageService.getValue(
StorageKey.RootKeyParams,
StorageValueModes.Nonwrapped,
)
expect(migratedKeyParams).to.eql(embeddedStorage.auth_params)
const rootKey = await application.protocolService.getRootKey()
expect(rootKey.masterKey).to.equal(passcodeKey.masterKey)
expect(rootKey.dataAuthenticationKey).to.equal(passcodeKey.dataAuthenticationKey)
/** Root key is in memory with passcode only, so server password can be defined */
expect(rootKey.serverPassword).to.be.ok
expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.WrapperOnly)
/** Expect note is decrypted */
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
const retrievedNote = application.itemManager.getDisplayableNotes()[0]
expect(retrievedNote.uuid).to.equal(notePayload.uuid)
expect(retrievedNote.content.text).to.equal(notePayload.content.text)
/** Ensure arbitrary values have been migrated */
for (const key of Object.keys(arbitraryValues)) {
const value = await application.diskStorageService.getValue(key)
expect(arbitraryValues[key]).to.equal(value)
}
await Factory.safeDeinit(application)
})
/**
* This test will pass but sync afterwards will not be successful
* as we are using a random value for the legacy session token
*/
it('2020-01-15 migration with account only', async function () {
const application = await Factory.createAppWithRandNamespace()
/** Create legacy migrations value so that base migration detects old app */
await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything']))
const operator003 = new SNProtocolOperator003(new FakeWebCrypto())
const identifier = 'foo'
/** Create old version account parameters */
const password = 'tar'
const accountKey = await operator003.createRootKey(identifier, password)
/** Create arbitrary storage values and make sure they're migrated */
const storage = {
foo: 'bar',
zar: 'tar',
har: 'car',
mk: accountKey.masterKey,
ak: accountKey.dataAuthenticationKey,
pw: accountKey.serverPassword,
jwt: 'anything',
/** Legacy versions would store json strings inside of embedded storage */
auth_params: JSON.stringify(accountKey.keyParams.getPortableValue()),
}
for (const key of Object.keys(storage)) {
await application.deviceInterface.setRawStorageValue(key, storage[key])
}
/** Create encrypted item and store it in db */
const notePayload = Factory.createNotePayload()
const noteEncryptionParams = await operator003.generateEncryptedParametersAsync(notePayload, accountKey)
const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams })
await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier)
/** Run migration */
const promptValueReply = (prompts) => {
const values = []
for (const prompt of prompts) {
if (prompt.validation === ChallengeValidation.LocalPasscode) {
values.push(CreateChallengeValue(prompt, passcode))
} else {
/** We will be prompted to reauthetnicate our session, not relevant to this test
* but pass any value to avoid exception
*/
values.push(CreateChallengeValue(prompt, 'foo'))
}
}
return values
}
const receiveChallenge = async (challenge) => {
application.addChallengeObserver(challenge, {
onInvalidValue: (value) => {
const values = promptValueReply([value.prompt])
application.submitValuesForChallenge(challenge, values)
},
})
const initialValues = promptValueReply(challenge.prompts)
application.submitValuesForChallenge(challenge, initialValues)
}
await application.prepareForLaunch({
receiveChallenge: receiveChallenge,
})
await application.launch(true)
expect(application.sessionManager.online()).to.equal(true)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly)
/** Should be decrypted */
const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default)
const valueStore = application.diskStorageService.values[storageMode]
expect(valueStore.content_type).to.not.be.ok
/** Embedded value should match */
const migratedKeyParams = await application.diskStorageService.getValue(
StorageKey.RootKeyParams,
StorageValueModes.Nonwrapped,
)
expect(migratedKeyParams).to.eql(accountKey.keyParams.getPortableValue())
const rootKey = await application.protocolService.getRootKey()
expect(rootKey).to.be.ok
expect(await application.deviceInterface.getRawStorageValue('migrations')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('auth_params')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('jwt')).to.not.be.ok
const keyParams = await application.diskStorageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped)
expect(typeof keyParams).to.equal('object')
expect(rootKey.masterKey).to.equal(accountKey.masterKey)
expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey)
expect(rootKey.serverPassword).to.not.be.ok
expect(rootKey.keyVersion).to.equal(ProtocolVersion.V003)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly)
/** Expect note is decrypted */
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
const retrievedNote = application.itemManager.getDisplayableNotes()[0]
expect(retrievedNote.uuid).to.equal(notePayload.uuid)
expect(retrievedNote.content.text).to.equal(notePayload.content.text)
/** Ensure arbitrary values have been migrated */
for (const key of Object.keys(storage)) {
/** Is stringified in storage, but parsed in storageService */
if (key === 'auth_params') {
continue
}
const value = await application.diskStorageService.getValue(key)
expect(storage[key]).to.equal(value)
}
console.warn('Expecting exception due to deiniting application while trying to renew session')
await Factory.safeDeinit(application)
})
it('2020-01-15 migration with no account and no passcode', async function () {
const application = await Factory.createAppWithRandNamespace()
/** Create legacy migrations value so that base migration detects old app */
await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything']))
/** Create arbitrary storage values and make sure they're migrated */
const storage = {
foo: 'bar',
zar: 'tar',
har: 'car',
}
for (const key of Object.keys(storage)) {
await application.deviceInterface.setRawStorageValue(key, storage[key])
}
/** Create item and store it in db */
const notePayload = Factory.createNotePayload()
await application.deviceInterface.saveRawDatabasePayload(notePayload.ejected(), application.identifier)
/** Run migration */
await application.prepareForLaunch({
receiveChallenge: (_challenge) => {
return null
},
})
await application.launch(true)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyNone)
/** Should be decrypted */
const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default)
const valueStore = application.diskStorageService.values[storageMode]
expect(valueStore.content_type).to.not.be.ok
const rootKey = await application.protocolService.getRootKey()
expect(rootKey).to.not.be.ok
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyNone)
expect(await application.deviceInterface.getRawStorageValue('migrations')).to.not.be.ok
/** Expect note is decrypted */
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
const retrievedNote = application.itemManager.getDisplayableNotes()[0]
expect(retrievedNote.uuid).to.equal(notePayload.uuid)
expect(retrievedNote.content.text).to.equal(notePayload.content.text)
/** Ensure arbitrary values have been migrated */
for (const key of Object.keys(storage)) {
const value = await application.diskStorageService.getValue(key)
expect(storage[key]).to.equal(value)
}
await Factory.safeDeinit(application)
})
/**
* This test will pass but sync afterwards will not be successful
* as we are using a random value for the legacy session token
*/
it('2020-01-15 migration from app v1.0.1 with account only', async function () {
const application = await Factory.createAppWithRandNamespace()
/** Create legacy migrations value so that base migration detects old app */
await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything']))
const operator001 = new SNProtocolOperator001(new FakeWebCrypto())
const identifier = 'foo'
/** Create old version account parameters */
const password = 'tar'
const accountKey = await operator001.createRootKey(identifier, password)
/** Create arbitrary storage values and make sure they're migrated */
const storage = {
mk: accountKey.masterKey,
pw: accountKey.serverPassword,
jwt: 'anything',
/** Legacy versions would store json strings inside of embedded storage */
auth_params: JSON.stringify(accountKey.keyParams.getPortableValue()),
user: JSON.stringify({ uuid: 'anything', email: 'anything' }),
}
for (const key of Object.keys(storage)) {
await application.deviceInterface.setRawStorageValue(key, storage[key])
}
/** Create encrypted item and store it in db */
const notePayload = Factory.createNotePayload()
const noteEncryptionParams = await operator001.generateEncryptedParametersAsync(notePayload, accountKey)
const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams })
await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier)
/** Run migration */
const promptValueReply = (prompts) => {
const values = []
for (const prompt of prompts) {
/** We will be prompted to reauthetnicate our session, not relevant to this test
* but pass any value to avoid exception
*/
values.push(CreateChallengeValue(prompt, 'foo'))
}
return values
}
const receiveChallenge = async (challenge) => {
application.addChallengeObserver(challenge, {
onInvalidValue: (value) => {
const values = promptValueReply([value.prompt])
application.submitValuesForChallenge(challenge, values)
},
})
const initialValues = promptValueReply(challenge.prompts)
application.submitValuesForChallenge(challenge, initialValues)
}
await application.prepareForLaunch({
receiveChallenge: receiveChallenge,
})
await application.launch(true)
expect(application.sessionManager.online()).to.equal(true)
expect(application.sessionManager.getUser()).to.be.ok
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly)
/** Should be decrypted */
const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default)
const valueStore = application.diskStorageService.values[storageMode]
expect(valueStore.content_type).to.not.be.ok
/** Embedded value should match */
const migratedKeyParams = await application.diskStorageService.getValue(
StorageKey.RootKeyParams,
StorageValueModes.Nonwrapped,
)
expect(migratedKeyParams).to.eql(accountKey.keyParams.getPortableValue())
const rootKey = await application.protocolService.getRootKey()
expect(rootKey).to.be.ok
expect(await application.deviceInterface.getRawStorageValue('migrations')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('auth_params')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('jwt')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('ak')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('mk')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('pw')).to.not.be.ok
const keyParams = await application.diskStorageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped)
expect(typeof keyParams).to.equal('object')
expect(rootKey.masterKey).to.equal(accountKey.masterKey)
expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey)
expect(rootKey.serverPassword).to.not.be.ok
expect(rootKey.keyVersion).to.equal(ProtocolVersion.V001)
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyOnly)
/** Expect note is decrypted */
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
const retrievedNote = application.itemManager.getDisplayableNotes()[0]
expect(retrievedNote.uuid).to.equal(notePayload.uuid)
expect(retrievedNote.content.text).to.equal(notePayload.content.text)
/** Ensure arbitrary values have been migrated */
for (const key of Object.keys(storage)) {
/** Is stringified in storage, but parsed in storageService */
const value = await application.diskStorageService.getValue(key)
if (key === 'auth_params') {
continue
} else if (key === 'user') {
expect(storage[key]).to.equal(JSON.stringify(value))
} else {
expect(storage[key]).to.equal(value)
}
}
await Factory.safeDeinit(application)
})
it('2020-01-15 migration from 002 app with account and passcode but missing offlineParams.version', async function () {
/**
* There was an issue where if the user had offlineParams but it was missing the version key,
* the user could not get past the passcode migration screen.
*/
const application = await Factory.createAppWithRandNamespace()
/** Create legacy migrations value so that base migration detects old app */
await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything']))
const operator002 = new SNProtocolOperator002(new FakeWebCrypto())
const identifier = 'foo'
const passcode = 'bar'
/** Create old version passcode parameters */
const passcodeKey = await operator002.createRootKey(identifier, passcode)
/** The primary chaos agent */
const offlineParams = passcodeKey.keyParams.getPortableValue()
omitInPlace(offlineParams, ['version'])
await application.deviceInterface.setRawStorageValue('offlineParams', JSON.stringify(offlineParams))
/** Create old version account parameters */
const password = 'tar'
const accountKey = await operator002.createRootKey(identifier, password)
/** Create legacy storage and encrypt it with passcode */
const embeddedStorage = {
mk: accountKey.masterKey,
ak: accountKey.dataAuthenticationKey,
pw: accountKey.serverPassword,
jwt: 'anything',
/** Legacy versions would store json strings inside of embedded storage */
auth_params: JSON.stringify(accountKey.keyParams.getPortableValue()),
user: JSON.stringify({ uuid: 'anything', email: 'anything' }),
}
const storagePayload = new DecryptedPayload({
uuid: await operator002.crypto.generateUUID(),
content_type: ContentType.EncryptedStorage,
content: {
storage: embeddedStorage,
},
})
const encryptionParams = await operator002.generateEncryptedParametersAsync(storagePayload, passcodeKey)
const persistPayload = new EncryptedPayload({ ...storagePayload, ...encryptionParams })
await application.deviceInterface.setRawStorageValue('encryptedStorage', JSON.stringify(persistPayload))
/** Create encrypted item and store it in db */
const notePayload = Factory.createNotePayload()
const noteEncryptionParams = await operator002.generateEncryptedParametersAsync(notePayload, accountKey)
const noteEncryptedPayload = new EncryptedPayload({ ...notePayload, ...noteEncryptionParams })
await application.deviceInterface.saveRawDatabasePayload(noteEncryptedPayload, application.identifier)
/** Runs migration */
await application.prepareForLaunch({
receiveChallenge: async (challenge) => {
application.submitValuesForChallenge(challenge, [CreateChallengeValue(challenge.prompts[0], passcode)])
},
})
await application.launch(true)
expect(application.sessionManager.online()).to.equal(true)
expect(application.sessionManager.getUser()).to.be.ok
expect(application.protocolService.rootKeyEncryption.keyMode).to.equal(KeyMode.RootKeyPlusWrapper)
/** Should be decrypted */
const storageMode = application.diskStorageService.domainKeyForMode(StorageValueModes.Default)
const valueStore = application.diskStorageService.values[storageMode]
expect(valueStore.content_type).to.not.be.ok
/** Embedded value should match */
const migratedKeyParams = await application.diskStorageService.getValue(
StorageKey.RootKeyParams,
StorageValueModes.Nonwrapped,
)
expect(migratedKeyParams).to.eql(accountKey.keyParams.getPortableValue())
const rootKey = await application.protocolService.getRootKey()
expect(rootKey).to.be.ok
expect(await application.deviceInterface.getRawStorageValue('migrations')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('auth_params')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('jwt')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('ak')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('mk')).to.not.be.ok
expect(await application.deviceInterface.getRawStorageValue('pw')).to.not.be.ok
const keyParams = await application.diskStorageService.getValue(StorageKey.RootKeyParams, StorageValueModes.Nonwrapped)
expect(typeof keyParams).to.equal('object')
expect(rootKey.masterKey).to.equal(accountKey.masterKey)
expect(rootKey.dataAuthenticationKey).to.equal(accountKey.dataAuthenticationKey)
expect(rootKey.serverPassword).to.not.be.ok
expect(rootKey.keyVersion).to.equal(ProtocolVersion.V002)
/** Expect note is decrypted */
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
const retrievedNote = application.itemManager.getDisplayableNotes()[0]
expect(retrievedNote.uuid).to.equal(notePayload.uuid)
expect(retrievedNote.content.text).to.equal(notePayload.content.text)
await Factory.safeDeinit(application)
})
})

View File

@@ -0,0 +1,175 @@
import * as Factory from '../lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('migrations', () => {
const allMigrations = ['2.0.0', '2.0.15', '2.7.0', '2.20.0', '2.36.0', '2.42.0']
beforeEach(async () => {
localStorage.clear()
})
afterEach(async () => {
localStorage.clear()
})
it('version number is stored as string', async function () {
const application = await Factory.createInitAppWithFakeCrypto()
const version = await application.migrationService.getStoredSnjsVersion()
expect(typeof version).to.equal('string')
await Factory.safeDeinit(application)
})
it('should return correct required migrations if stored version is 1.0.0', async function () {
expect((await SNMigrationService.getRequiredMigrations('1.0.0')).length).to.equal(allMigrations.length)
})
it('should return correct required migrations if stored version is 2.0.0', async function () {
expect((await SNMigrationService.getRequiredMigrations('2.0.0')).length).to.equal(allMigrations.length - 1)
})
it('should return 0 required migrations if stored version is futuristic', async function () {
expect((await SNMigrationService.getRequiredMigrations('100.0.1')).length).to.equal(0)
})
it('after running base migration, legacy structure should set version as 1.0.0', async function () {
const application = await Factory.createAppWithRandNamespace()
/** Set up 1.0.0 structure with tell-tale storage key */
await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything']))
await application.migrationService.runBaseMigrationPreRun()
expect(await application.migrationService.getStoredSnjsVersion()).to.equal('1.0.0')
await Factory.safeDeinit(application)
})
it('after running base migration, 2.0.0 structure set version as 2.0.0', async function () {
const application = await Factory.createAppWithRandNamespace()
/** Set up 2.0.0 structure with tell-tale storage key */
await application.deviceInterface.setRawStorageValue(
namespacedKey(application.identifier, 'last_migration_timestamp'),
'anything',
)
await application.migrationService.runBaseMigrationPreRun()
expect(await application.migrationService.getStoredSnjsVersion()).to.equal('2.0.0')
await Factory.safeDeinit(application)
})
it('after running base migration with no present storage values, should set version to current', async function () {
const application = await Factory.createAppWithRandNamespace()
await application.migrationService.runBaseMigrationPreRun()
expect(await application.migrationService.getStoredSnjsVersion()).to.equal(SnjsVersion)
await Factory.safeDeinit(application)
})
it('after running all migrations from a 1.0.0 installation, should set stored version to current', async function () {
const application = await Factory.createAppWithRandNamespace()
/** Set up 1.0.0 structure with tell-tale storage key */
await application.deviceInterface.setRawStorageValue('migrations', JSON.stringify(['anything']))
await application.prepareForLaunch({
receiveChallenge: () => {},
})
await application.launch(true)
expect(await application.migrationService.getStoredSnjsVersion()).to.equal(SnjsVersion)
await Factory.safeDeinit(application)
})
it('after running all migrations from a 2.0.0 installation, should set stored version to current', async function () {
const application = await Factory.createAppWithRandNamespace()
/** Set up 2.0.0 structure with tell-tale storage key */
await application.deviceInterface.setRawStorageValue('last_migration_timestamp', JSON.stringify(['anything']))
await application.prepareForLaunch({
receiveChallenge: () => {},
})
await application.launch(true)
expect(await application.migrationService.getStoredSnjsVersion()).to.equal(SnjsVersion)
await Factory.safeDeinit(application)
})
it('should be correct migration count coming from 1.0.0', async function () {
const application = await Factory.createAppWithRandNamespace()
await application.deviceInterface.setRawStorageValue('migrations', 'anything')
await application.migrationService.runBaseMigrationPreRun()
expect(await application.migrationService.getStoredSnjsVersion()).to.equal('1.0.0')
const pendingMigrations = await SNMigrationService.getRequiredMigrations(
await application.migrationService.getStoredSnjsVersion(),
)
expect(pendingMigrations.length).to.equal(allMigrations.length)
expect(pendingMigrations[0].version()).to.equal('2.0.0')
await application.prepareForLaunch({
receiveChallenge: () => {},
})
await application.launch(true)
expect(await application.migrationService.getStoredSnjsVersion()).to.equal(SnjsVersion)
await Factory.safeDeinit(application)
})
it('2.20.0 remove mfa migration', async function () {
const application = await Factory.createAppWithRandNamespace()
await application.prepareForLaunch({
receiveChallenge: () => {},
})
await application.launch(true)
const mfaItem = CreateDecryptedItemFromPayload(
new DecryptedPayload({
uuid: '123',
content_type: 'SF|MFA',
content: FillItemContent({
key: '123',
}),
}),
)
await application.mutator.insertItem(mfaItem)
await application.sync.sync()
expect(application.items.getItems('SF|MFA').length).to.equal(1)
expect(
(await application.diskStorageService.getAllRawPayloads()).filter((p) => p.content_type === 'SF|MFA').length,
).to.equal(1)
/** Run migration */
const migration = new Migration2_20_0(application.migrationService.services)
await migration.handleStage(ApplicationStage.LoadedDatabase_12)
expect(application.items.getItems('SF|MFA').length).to.equal(0)
expect(
(await application.diskStorageService.getAllRawPayloads()).filter((p) => p.content_type === 'SF|MFA').length,
).to.equal(0)
await Factory.safeDeinit(application)
})
it('2.42.0 remove no distraction theme', async function () {
const application = await Factory.createAppWithRandNamespace()
await application.prepareForLaunch({
receiveChallenge: () => {},
})
await application.launch(true)
const noDistractionItem = CreateDecryptedItemFromPayload(
new DecryptedPayload({
uuid: '123',
content_type: ContentType.Theme,
content: FillItemContent({
package_info: {
identifier: 'org.standardnotes.theme-no-distraction',
},
}),
}),
)
await application.mutator.insertItem(noDistractionItem)
await application.sync.sync()
expect(application.items.getItems(ContentType.Theme).length).to.equal(1)
/** Run migration */
const migration = new Migration2_42_0(application.migrationService.services)
await migration.handleStage(ApplicationStage.FullSyncCompleted_13)
await application.sync.sync()
expect(application.items.getItems(ContentType.Theme).length).to.equal(0)
await Factory.safeDeinit(application)
})
})

View File

@@ -0,0 +1,191 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
const setupRandomUuid = () => {
let currentId = 0
UuidGenerator.SetGenerator(() => String(currentId++))
}
describe('web native folders migration', () => {
beforeEach(async function () {
this.application = await Factory.createInitAppWithFakeCrypto()
setupRandomUuid()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
// TODO: cleanup uuid behind us or we'll mess other tests.
})
it('migration with flat tag folders', async function () {
const titles = ['a', 'b', 'c']
await makeTags(this.application, titles)
// Run the migration
await this.application.mutator.migrateTagsToFolders()
// Check new tags
const result = extractTagHierarchy(this.application)
expect(result).to.deep.equal({
a: { _uuid: 'a' },
b: { _uuid: 'b' },
c: { _uuid: 'c' },
})
})
it('migration with simple tag folders', async function () {
const titles = ['a.b.c', 'b', 'a.b']
await makeTags(this.application, titles)
// Run the migration
await this.application.mutator.migrateTagsToFolders()
// Check new tags
const result = extractTagHierarchy(this.application)
expect(result).to.deep.equal({
a: {
_uuid: '0',
b: {
_uuid: 'a.b',
c: { _uuid: 'a.b.c' },
},
},
b: { _uuid: 'b' },
})
})
it('migration with more complex cases', async function () {
const titles = ['a.b.c', 'b', 'a.b']
await makeTags(this.application, titles)
// Run the migration
await this.application.mutator.migrateTagsToFolders()
// Check new tags
const result = extractTagHierarchy(this.application)
expect(result).to.deep.equal({
a: {
_uuid: '0',
b: {
_uuid: 'a.b',
c: { _uuid: 'a.b.c' },
},
},
b: { _uuid: 'b' },
})
})
it('should produce a valid hierarchy cases with missing intermediate tags or unordered', async function () {
const titles = ['y.2', 'w.3', 'y']
await makeTags(this.application, titles)
// Run the migration
await this.application.mutator.migrateTagsToFolders()
// Check new tags
const result = extractTagHierarchy(this.application)
expect(result).to.deep.equal({
w: {
_uuid: '0',
3: {
_uuid: 'w.3',
},
},
y: { _uuid: 'y', 2: { _uuid: 'y.2' } },
})
})
it('skip prefixed names', async function () {
const titles = ['.something', '.something...something', 'something.a.b.c']
await makeTags(this.application, titles)
// Run the migration
await this.application.mutator.migrateTagsToFolders()
// Check new tags
const result = extractTagHierarchy(this.application)
expect(result).to.deep.equal({
'.something': { _uuid: '.something' },
'.something...something': { _uuid: '.something...something' },
something: {
_uuid: '0',
a: { _uuid: '1', b: { _uuid: '2', c: { _uuid: 'something.a.b.c' } } },
},
})
})
it('skip not-supported names', async function () {
const titles = [
'something.',
'something..',
'something..another.thing',
'a.b.c',
'a',
'something..another.thing..anyway',
]
await makeTags(this.application, titles)
// Run the migration
await this.application.mutator.migrateTagsToFolders()
// Check new tags
const result = extractTagHierarchy(this.application)
expect(result).to.deep.equal({
'something.': { _uuid: 'something.' },
'something..': { _uuid: 'something..' },
'something..another.thing': { _uuid: 'something..another.thing' },
'something..another.thing..anyway': {
_uuid: 'something..another.thing..anyway',
},
a: {
_uuid: 'a',
b: {
_uuid: '0',
c: {
_uuid: 'a.b.c',
},
},
},
})
})
})
const makeTags = async (application, titles) => {
const createTag = (title) => {
return Factory.createMappedTag(application, { title, uuid: title })
}
const tags = await Promise.all(titles.map(createTag))
return tags
}
const extractTagHierarchy = (application) => {
const result = {}
const roots = application.itemManager.getRootTags()
const constructHierarchy = (currentTag, result) => {
result[currentTag.title] = { _uuid: currentTag.uuid }
const children = application.items.getTagChildren(currentTag)
children.forEach((child) => {
constructHierarchy(child, result[currentTag.title])
})
}
roots.forEach((tag) => {
constructHierarchy(tag, result)
})
return result
}

View File

@@ -0,0 +1,374 @@
/* eslint-disable camelcase */
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('app models', () => {
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
const sharedApplication = Factory.createApplicationWithFakeCrypto()
before(async function () {
localStorage.clear()
await Factory.initializeApplication(sharedApplication)
})
after(async function () {
localStorage.clear()
await Factory.safeDeinit(sharedApplication)
})
beforeEach(async function () {
this.expectedItemCount = BASE_ITEM_COUNT
this.application = await Factory.createInitAppWithFakeCrypto()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
})
it('payloadManager should be defined', () => {
expect(sharedApplication.payloadManager).to.be.ok
})
it('item should be defined', () => {
expect(GenericItem).to.be.ok
})
it('item content should be assigned', () => {
const params = Factory.createNotePayload()
const item = CreateDecryptedItemFromPayload(params)
expect(item.content.title).to.equal(params.content.title)
})
it('should default updated_at to 1970 and created_at to the present', () => {
const params = Factory.createNotePayload()
const item = CreateDecryptedItemFromPayload(params)
const epoch = new Date(0)
expect(item.serverUpdatedAt - epoch).to.equal(0)
expect(item.created_at - epoch).to.be.above(0)
expect(new Date() - item.created_at).to.be.below(5) // < 5ms
})
it('handles delayed mapping', async function () {
const params1 = Factory.createNotePayload()
const params2 = Factory.createNotePayload()
const mutated = new DecryptedPayload({
...params1,
content: {
...params1.content,
references: [
{
uuid: params2.uuid,
content_type: params2.content_type,
},
],
},
})
await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged)
await this.application.itemManager.emitItemsFromPayloads([params2], PayloadEmitSource.LocalChanged)
const item1 = this.application.itemManager.findItem(params1.uuid)
const item2 = this.application.itemManager.findItem(params2.uuid)
expect(item1.content.references.length).to.equal(1)
expect(item2.content.references.length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(item1).length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(1)
})
it('mapping an item twice shouldnt cause problems', async function () {
const payload = Factory.createNotePayload()
const mutated = new DecryptedPayload({
...payload,
content: {
...payload.content,
foo: 'bar',
},
})
let items = await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged)
let item = items[0]
expect(item).to.be.ok
items = await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged)
item = items[0]
expect(item.content.foo).to.equal('bar')
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1)
})
it('mapping item twice should preserve references', async function () {
const item1 = await Factory.createMappedNote(this.application)
const item2 = await Factory.createMappedNote(this.application)
await this.application.itemManager.changeItem(item1, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item2)
})
await this.application.itemManager.changeItem(item2, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item1)
})
const refreshedItem = this.application.itemManager.findItem(item1.uuid)
expect(refreshedItem.content.references.length).to.equal(1)
})
it('fixes relationship integrity', async function () {
var item1 = await Factory.createMappedNote(this.application)
var item2 = await Factory.createMappedNote(this.application)
await this.application.itemManager.changeItem(item1, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item2)
})
await this.application.itemManager.changeItem(item2, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item1)
})
const refreshedItem1 = this.application.itemManager.findItem(item1.uuid)
const refreshedItem2 = this.application.itemManager.findItem(item2.uuid)
expect(refreshedItem1.content.references.length).to.equal(1)
expect(refreshedItem2.content.references.length).to.equal(1)
const damagedPayload = refreshedItem1.payload.copy({
content: {
...refreshedItem1.content,
// damage references of one object
references: [],
},
})
await this.application.itemManager.emitItemsFromPayloads([damagedPayload], PayloadEmitSource.LocalChanged)
const refreshedItem1_2 = this.application.itemManager.findItem(item1.uuid)
const refreshedItem2_2 = this.application.itemManager.findItem(item2.uuid)
expect(refreshedItem1_2.content.references.length).to.equal(0)
expect(refreshedItem2_2.content.references.length).to.equal(1)
})
it('creating and removing relationships between two items should have valid references', async function () {
var item1 = await Factory.createMappedNote(this.application)
var item2 = await Factory.createMappedNote(this.application)
await this.application.itemManager.changeItem(item1, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item2)
})
await this.application.itemManager.changeItem(item2, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item1)
})
const refreshedItem1 = this.application.itemManager.findItem(item1.uuid)
const refreshedItem2 = this.application.itemManager.findItem(item2.uuid)
expect(refreshedItem1.content.references.length).to.equal(1)
expect(refreshedItem2.content.references.length).to.equal(1)
expect(this.application.itemManager.itemsReferencingItem(item1)).to.include(refreshedItem2)
expect(this.application.itemManager.itemsReferencingItem(item2)).to.include(refreshedItem1)
await this.application.itemManager.changeItem(item1, (mutator) => {
mutator.removeItemAsRelationship(item2)
})
await this.application.itemManager.changeItem(item2, (mutator) => {
mutator.removeItemAsRelationship(item1)
})
const refreshedItem1_2 = this.application.itemManager.findItem(item1.uuid)
const refreshedItem2_2 = this.application.itemManager.findItem(item2.uuid)
expect(refreshedItem1_2.content.references.length).to.equal(0)
expect(refreshedItem2_2.content.references.length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(item1).length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(0)
})
it('properly duplicates item with no relationships', async function () {
const item = await Factory.createMappedNote(this.application)
const duplicate = await this.application.itemManager.duplicateItem(item)
expect(duplicate.uuid).to.not.equal(item.uuid)
expect(item.isItemContentEqualWith(duplicate)).to.equal(true)
expect(item.created_at.toISOString()).to.equal(duplicate.created_at.toISOString())
expect(item.content_type).to.equal(duplicate.content_type)
})
it('properly duplicates item with relationships', async function () {
const item1 = await Factory.createMappedNote(this.application)
const item2 = await Factory.createMappedNote(this.application)
const refreshedItem1 = await this.application.itemManager.changeItem(item1, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item2)
})
expect(refreshedItem1.content.references.length).to.equal(1)
const duplicate = await this.application.itemManager.duplicateItem(item1)
expect(duplicate.uuid).to.not.equal(item1.uuid)
expect(duplicate.content.references.length).to.equal(1)
expect(this.application.itemManager.itemsReferencingItem(item1).length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(2)
const refreshedItem1_2 = this.application.itemManager.findItem(item1.uuid)
expect(refreshedItem1_2.isItemContentEqualWith(duplicate)).to.equal(true)
expect(refreshedItem1_2.created_at.toISOString()).to.equal(duplicate.created_at.toISOString())
expect(refreshedItem1_2.content_type).to.equal(duplicate.content_type)
})
it('removing references should update cross-refs', async function () {
const item1 = await Factory.createMappedNote(this.application)
const item2 = await Factory.createMappedNote(this.application)
const refreshedItem1 = await this.application.itemManager.changeItem(item1, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item2)
})
const refreshedItem1_2 = await this.application.itemManager.emitItemFromPayload(
refreshedItem1.payloadRepresentation({
deleted: true,
content: {
...refreshedItem1.payload.content,
references: [],
},
}),
PayloadEmitSource.LocalChanged,
)
expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(item1).length).to.equal(0)
expect(refreshedItem1_2.content.references.length).to.equal(0)
})
it('properly handles single item uuid alternation', async function () {
const item1 = await Factory.createMappedNote(this.application)
const item2 = await Factory.createMappedNote(this.application)
const refreshedItem1 = await this.application.itemManager.changeItem(item1, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item2)
})
expect(refreshedItem1.content.references.length).to.equal(1)
expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(1)
const alternatedItem = await Factory.alternateUuidForItem(this.application, item1.uuid)
const refreshedItem1_2 = this.application.itemManager.findItem(item1.uuid)
expect(refreshedItem1_2).to.not.be.ok
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(2)
expect(alternatedItem.content.references.length).to.equal(1)
expect(this.application.itemManager.itemsReferencingItem(alternatedItem.uuid).length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(1)
expect(alternatedItem.isReferencingItem(item2)).to.equal(true)
expect(alternatedItem.dirty).to.equal(true)
})
it('alterating uuid of item should fill its duplicateOf value', async function () {
const item1 = await Factory.createMappedNote(this.application)
const alternatedItem = await Factory.alternateUuidForItem(this.application, item1.uuid)
expect(alternatedItem.duplicateOf).to.equal(item1.uuid)
})
it('alterating itemskey uuid should update errored items encrypted with that key', async function () {
const item1 = await Factory.createMappedNote(this.application)
const itemsKey = this.application.itemManager.getDisplayableItemsKeys()[0]
/** Encrypt item1 and emit as errored so it persists with items_key_id */
const encrypted = await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [item1.payload],
},
})
const errored = encrypted.copy({
errorDecrypting: true,
waitingForKey: true,
})
await this.application.itemManager.emitItemFromPayload(errored)
expect(this.application.payloadManager.findOne(item1.uuid).errorDecrypting).to.equal(true)
expect(this.application.payloadManager.findOne(item1.uuid).items_key_id).to.equal(itemsKey.uuid)
sinon.stub(this.application.protocolService.itemsEncryption, 'decryptErroredPayloads').callsFake(() => {
// prevent auto decryption
})
const alternatedKey = await Factory.alternateUuidForItem(this.application, itemsKey.uuid)
const updatedPayload = this.application.payloadManager.findOne(item1.uuid)
expect(updatedPayload.items_key_id).to.equal(alternatedKey.uuid)
})
it('properly handles mutli item uuid alternation', async function () {
const item1 = await Factory.createMappedNote(this.application)
const item2 = await Factory.createMappedNote(this.application)
this.expectedItemCount += 2
await this.application.itemManager.changeItem(item1, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item2)
})
expect(this.application.itemManager.itemsReferencingItem(item2).length).to.equal(1)
const alternatedItem1 = await Factory.alternateUuidForItem(this.application, item1.uuid)
const alternatedItem2 = await Factory.alternateUuidForItem(this.application, item2.uuid)
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
expect(item1.uuid).to.not.equal(alternatedItem1.uuid)
expect(item2.uuid).to.not.equal(alternatedItem2.uuid)
const refreshedAltItem1 = this.application.itemManager.findItem(alternatedItem1.uuid)
expect(refreshedAltItem1.content.references.length).to.equal(1)
expect(refreshedAltItem1.content.references[0].uuid).to.equal(alternatedItem2.uuid)
expect(alternatedItem2.content.references.length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(alternatedItem2).length).to.equal(1)
expect(refreshedAltItem1.isReferencingItem(alternatedItem2)).to.equal(true)
expect(alternatedItem2.isReferencingItem(refreshedAltItem1)).to.equal(false)
expect(refreshedAltItem1.dirty).to.equal(true)
})
it('maintains referencing relationships when duplicating', async function () {
const tag = await Factory.createMappedTag(this.application)
const note = await Factory.createMappedNote(this.application)
const refreshedTag = await this.application.itemManager.changeItem(tag, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(note)
})
expect(refreshedTag.content.references.length).to.equal(1)
const noteCopy = await this.application.itemManager.duplicateItem(note)
expect(note.uuid).to.not.equal(noteCopy.uuid)
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(2)
expect(this.application.itemManager.getDisplayableTags().length).to.equal(1)
expect(note.content.references.length).to.equal(0)
expect(noteCopy.content.references.length).to.equal(0)
const refreshedTag_2 = this.application.itemManager.findItem(tag.uuid)
expect(refreshedTag_2.content.references.length).to.equal(2)
})
it('maintains editor reference when duplicating note', async function () {
const note = await Factory.createMappedNote(this.application)
const editor = await this.application.itemManager.createItem(
ContentType.Component,
{ area: ComponentArea.Editor },
true,
)
await this.application.itemManager.changeComponent(editor, (mutator) => {
mutator.associateWithItem(note.uuid)
})
expect(this.application.componentManager.editorForNote(note).uuid).to.equal(editor.uuid)
const duplicate = await this.application.itemManager.duplicateItem(note, true)
expect(this.application.componentManager.editorForNote(duplicate).uuid).to.equal(editor.uuid)
})
})

View File

@@ -0,0 +1,871 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
import { createRelatedNoteTagPairPayload } from '../lib/Items.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('importing', function () {
this.timeout(Factory.TenSecondTimeout)
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
let expectedItemCount
let application
let email
let password
beforeEach(function () {
localStorage.clear()
})
const setup = async ({ fakeCrypto }) => {
expectedItemCount = BASE_ITEM_COUNT
if (fakeCrypto) {
application = await Factory.createInitAppWithFakeCrypto()
} else {
application = await Factory.createInitAppWithRealCrypto()
}
email = UuidGenerator.GenerateUuid()
password = UuidGenerator.GenerateUuid()
Factory.handlePasswordChallenges(application, password)
}
afterEach(async function () {
await Factory.safeDeinit(application)
localStorage.clear()
})
it('should not import backups made from unsupported versions', async function () {
await setup({ fakeCrypto: true })
const result = await application.mutator.importData({
version: '-1',
items: [],
})
expect(result.error).to.exist
})
it('should not import backups made from 004 into 003 account', async function () {
await setup({ fakeCrypto: true })
await Factory.registerOldUser({
application,
email,
password,
version: ProtocolVersion.V003,
})
const result = await application.mutator.importData({
version: ProtocolVersion.V004,
items: [],
})
expect(result.error).to.exist
})
it('importing existing data should keep relationships valid', async function () {
await setup({ fakeCrypto: true })
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
await application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged)
expectedItemCount += 2
const note = application.itemManager.getItems([ContentType.Note])[0]
const tag = application.itemManager.getItems([ContentType.Tag])[0]
expect(tag.content.references.length).to.equal(1)
expect(tag.noteCount).to.equal(1)
expect(note.content.references.length).to.equal(0)
expect(application.itemManager.itemsReferencingItem(note).length).to.equal(1)
await application.mutator.importData(
{
items: [notePayload, tagPayload],
},
true,
)
expect(application.itemManager.items.length).to.equal(expectedItemCount)
expect(tag.content.references.length).to.equal(1)
expect(tag.noteCount).to.equal(1)
expect(note.content.references.length).to.equal(0)
expect(application.itemManager.itemsReferencingItem(note).length).to.equal(1)
})
it('importing same note many times should create only one duplicate', async function () {
/**
* Used strategy here will be KEEP_LEFT_DUPLICATE_RIGHT
* which means that new right items will be created with different
*/
await setup({ fakeCrypto: true })
const notePayload = Factory.createNotePayload()
await application.itemManager.emitItemFromPayload(notePayload, PayloadEmitSource.LocalChanged)
expectedItemCount++
const mutatedNote = new DecryptedPayload({
...notePayload,
content: {
...notePayload.content,
title: `${Math.random()}`,
},
})
await application.mutator.importData(
{
items: [mutatedNote, mutatedNote, mutatedNote],
},
true,
)
expectedItemCount++
expect(application.itemManager.getDisplayableNotes().length).to.equal(2)
const imported = application.itemManager.getDisplayableNotes().find((n) => n.uuid !== notePayload.uuid)
expect(imported.content.title).to.equal(mutatedNote.content.title)
})
it('importing a tag with lesser references should not create duplicate', async function () {
await setup({ fakeCrypto: true })
const pair = createRelatedNoteTagPairPayload()
const tagPayload = pair[1]
await application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged)
const mutatedTag = new DecryptedPayload({
...tagPayload,
content: {
...tagPayload.content,
references: [],
},
})
await application.mutator.importData(
{
items: [mutatedTag],
},
true,
)
expect(application.itemManager.getDisplayableTags().length).to.equal(1)
expect(application.itemManager.findItem(tagPayload.uuid).content.references.length).to.equal(1)
})
it('importing data with differing content should create duplicates', async function () {
await setup({ fakeCrypto: true })
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
await application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged)
expectedItemCount += 2
const note = application.itemManager.getDisplayableNotes()[0]
const tag = application.itemManager.getDisplayableTags()[0]
const mutatedNote = new DecryptedPayload({
...notePayload,
content: {
...notePayload.content,
title: `${Math.random()}`,
},
})
const mutatedTag = new DecryptedPayload({
...tagPayload,
content: {
...tagPayload.content,
title: `${Math.random()}`,
},
})
await application.mutator.importData(
{
items: [mutatedNote, mutatedTag],
},
true,
)
expectedItemCount += 2
expect(application.itemManager.items.length).to.equal(expectedItemCount)
const newNote = application.itemManager.getDisplayableNotes().find((n) => n.uuid !== notePayload.uuid)
const newTag = application.itemManager.getDisplayableTags().find((t) => t.uuid !== tagPayload.uuid)
expect(newNote.uuid).to.not.equal(note.uuid)
expect(newTag.uuid).to.not.equal(tag.uuid)
const refreshedTag = application.itemManager.findItem(tag.uuid)
expect(refreshedTag.content.references.length).to.equal(2)
expect(refreshedTag.noteCount).to.equal(2)
const refreshedNote = application.itemManager.findItem(note.uuid)
expect(refreshedNote.content.references.length).to.equal(0)
expect(application.itemManager.itemsReferencingItem(refreshedNote).length).to.equal(2)
expect(newTag.content.references.length).to.equal(1)
expect(newTag.noteCount).to.equal(1)
expect(newNote.content.references.length).to.equal(0)
expect(application.itemManager.itemsReferencingItem(newNote).length).to.equal(1)
})
it('when importing items, imported values should not be used to determine if changed', async function () {
/**
* If you have a note and a tag, and the tag has 1 reference to the note,
* and you import the same two items, except modify the note value so that
* a duplicate is created, we expect only the note to be duplicated, and the
* tag not to. However, if only the note changes, and you duplicate the note,
* which causes the tag's references content to change, then when the incoming
* tag is being processed, it will also think it has changed, since our local
* value now doesn't match what's coming in. The solution is to get all values
* ahead of time before any changes are made.
*/
await setup({ fakeCrypto: true })
const note = await Factory.createMappedNote(application)
const tag = await Factory.createMappedTag(application)
expectedItemCount += 2
await application.itemManager.changeItem(tag, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(note)
})
const externalNote = Object.assign(
{},
{
uuid: note.uuid,
content: note.getContentCopy(),
content_type: note.content_type,
},
)
externalNote.content.text = `${Math.random()}`
const externalTag = Object.assign(
{},
{
uuid: tag.uuid,
content: tag.getContentCopy(),
content_type: tag.content_type,
},
)
await application.mutator.importData(
{
items: [externalNote, externalTag],
},
true,
)
expectedItemCount += 1
/** We expect now that the total item count is 3, not 4. */
expect(application.itemManager.items.length).to.equal(expectedItemCount)
const refreshedTag = application.itemManager.findItem(tag.uuid)
/** References from both items have merged. */
expect(refreshedTag.content.references.length).to.equal(2)
})
it('should import decrypted data and keep items that were previously deleted', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
Factory.handlePasswordChallenges(application, password)
const [note, tag] = await Promise.all([Factory.createMappedNote(application), Factory.createMappedTag(application)])
await application.sync.sync({ awaitAll: true })
await application.mutator.deleteItem(note)
expect(application.items.findItem(note.uuid)).to.not.exist
await application.mutator.deleteItem(tag)
expect(application.items.findItem(tag.uuid)).to.not.exist
await application.mutator.importData(
{
items: [note, tag],
},
true,
)
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
expect(application.items.findItem(note.uuid).deleted).to.not.be.ok
expect(application.itemManager.getDisplayableTags().length).to.equal(1)
expect(application.items.findItem(tag.uuid).deleted).to.not.be.ok
})
it('should duplicate notes by alternating UUIDs when dealing with conflicts during importing', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
const note = await Factory.createSyncedNote(application)
/** Sign into another account and import the same item. It should get a different UUID. */
application = await Factory.signOutApplicationAndReturnNew(application)
email = UuidGenerator.GenerateUuid()
Factory.handlePasswordChallenges(application, password)
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
await application.mutator.importData(
{
items: [note.payload],
},
true,
)
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
expect(application.itemManager.getDisplayableNotes()[0].uuid).to.not.equal(note.uuid)
})
it('should maintain consistency between storage and PayloadManager after an import with conflicts', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
const note = await Factory.createSyncedNote(application)
/** Sign into another account and import the same items. They should get a different UUID. */
application = await Factory.signOutApplicationAndReturnNew(application)
email = UuidGenerator.GenerateUuid()
Factory.handlePasswordChallenges(application, password)
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
await application.mutator.importData(
{
items: [note],
},
true,
)
const storedPayloads = await application.diskStorageService.getAllRawPayloads()
expect(application.itemManager.items.length).to.equal(storedPayloads.length)
const notes = storedPayloads.filter((p) => p.content_type === ContentType.Note)
const itemsKeys = storedPayloads.filter((p) => p.content_type === ContentType.ItemsKey)
expect(notes.length).to.equal(1)
expect(itemsKeys.length).to.equal(1)
})
it('should import encrypted data and keep items that were previously deleted', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
const [note, tag] = await Promise.all([Factory.createMappedNote(application), Factory.createMappedTag(application)])
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
await application.sync.sync({ awaitAll: true })
await application.mutator.deleteItem(note)
expect(application.items.findItem(note.uuid)).to.not.exist
await application.mutator.deleteItem(tag)
expect(application.items.findItem(tag.uuid)).to.not.exist
await application.mutator.importData(backupData, true)
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
expect(application.items.findItem(note.uuid).deleted).to.not.be.ok
expect(application.itemManager.getDisplayableTags().length).to.equal(1)
expect(application.items.findItem(tag.uuid).deleted).to.not.be.ok
})
it('should import decrypted data and all items payload source should be FileImport', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
const [note, tag] = await Promise.all([Factory.createMappedNote(application), Factory.createMappedTag(application)])
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
await Factory.safeDeinit(application)
application = await Factory.createInitAppWithFakeCrypto()
Factory.handlePasswordChallenges(application, password)
await application.mutator.importData(backupData, true)
const importedNote = application.items.findItem(note.uuid)
const importedTag = application.items.findItem(tag.uuid)
expect(importedNote.payload.source).to.be.equal(PayloadSource.FileImport)
expect(importedTag.payload.source).to.be.equal(PayloadSource.FileImport)
})
it('should import encrypted data and all items payload source should be FileImport', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
const [note, tag] = await Promise.all([Factory.createMappedNote(application), Factory.createMappedTag(application)])
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
await Factory.safeDeinit(application)
application = await Factory.createInitAppWithFakeCrypto()
Factory.handlePasswordChallenges(application, password)
await application.mutator.importData(backupData, true)
const importedNote = application.items.findItem(note.uuid)
const importedTag = application.items.findItem(tag.uuid)
expect(importedNote.payload.source).to.be.equal(PayloadSource.FileImport)
expect(importedTag.payload.source).to.be.equal(PayloadSource.FileImport)
})
it('should import data from 003 encrypted payload using client generated backup', async function () {
await setup({ fakeCrypto: true })
const oldVersion = ProtocolVersion.V003
await Factory.registerOldUser({
application: application,
email: email,
password: password,
version: oldVersion,
})
const noteItem = await application.itemManager.createItem(ContentType.Note, {
title: 'Encrypted note',
text: 'On protocol version 003.',
})
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
await Factory.safeDeinit(application)
application = await Factory.createInitAppWithFakeCrypto()
Factory.handlePasswordChallenges(application, password)
const result = await application.mutator.importData(backupData, true)
expect(result).to.not.be.undefined
expect(result.affectedItems.length).to.be.eq(backupData.items.length)
expect(result.errorCount).to.be.eq(0)
const decryptedNote = application.itemManager.findItem(noteItem.uuid)
expect(decryptedNote.title).to.be.eq('Encrypted note')
expect(decryptedNote.text).to.be.eq('On protocol version 003.')
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
})
it('should import data from 003 encrypted payload using server generated backup with 004 key params', async function () {
await setup({ fakeCrypto: false })
const backupData = {
items: [
{
uuid: 'eb1b7eed-e43d-48dd-b257-b7fc8ccba3da',
duplicate_of: null,
items_key_id: null,
content:
'003:618138e365a13f8aed17d4f52e3da47d4b5d6e02004a0f827118e8a981a57c35:eb1b7eed-e43d-48dd-b257-b7fc8ccba3da:9f38642b7a3f57546520a9e32aa7c0ad:qa9rUcaD904m1Knv63dnATEHwfHJjsbq9bWb06zGTsyQxzLaAYT7uRGp2KB2g1eo5Aqxc5FqhvuF0+dE1f4+uQOeiRFNX73V2pJJY0w5Qq7l7ZuhB08ZtOMY4Ctq7evBBSIVZ+PEIfFnACelNJhsB5Uhn3kS4ZBx6qtvQ6ciSQGfYAwc6wSKhjUm1umEINeb08LNgwbP6XAm8U/la1bdtdMO112XjUW7ixkWi3POWcM=:eyJpZGVudGlmaWVyIjoicGxheWdyb3VuZEBiaXRhci5pbyIsInB3X2Nvc3QiOjExMDAwMCwicHdfbm9uY2UiOiJhZmIwYjE3NGJlYjViMmJmZTIyNTk1NDlmMTgxNDI1NzlkMDE1ZmE3ZTBhMjE4YzVmNDIxNmU0Mzg2ZGI3OWFiIiwidmVyc2lvbiI6IjAwMyJ9',
content_type: 'Note',
enc_item_key:
'003:5a01e913c52899ba10c16dbe7e713dd9caf9b9554c82176ddfcf1424f5bfd94f:eb1b7eed-e43d-48dd-b257-b7fc8ccba3da:14721ff8dbdd36fb57ae4bf7414c5eab:odmq91dfaTZG/zeSUA09fD/PdB2OkiDxcQZ0FL06GPstxdvxnU17k1rtsWoA7HoNNnd5494BZ/b7YiKqUb76ddd8x3/+cTZgCa4tYxNINmb1T3wwUX0Ebxc8xynAhg6nTY/BGq+ba6jTyl8zw12dL3kBEGGglRCHnO0ZTeylwQW7asfONN8s0BwrvHdonRlx:eyJpZGVudGlmaWVyIjoicGxheWdyb3VuZEBiaXRhci5pbyIsInB3X2Nvc3QiOjExMDAwMCwicHdfbm9uY2UiOiJhZmIwYjE3NGJlYjViMmJmZTIyNTk1NDlmMTgxNDI1NzlkMDE1ZmE3ZTBhMjE4YzVmNDIxNmU0Mzg2ZGI3OWFiIiwidmVyc2lvbiI6IjAwMyJ9',
auth_hash: null,
created_at: '2019-05-12T02:29:21.789000Z',
updated_at: '2019-11-12T21:47:48.382708Z',
deleted: false,
},
{
uuid: '10051be7-4ca2-4af3-aae9-021939df4fab',
duplicate_of: null,
items_key_id: null,
content:
'004:77a986823b8ffdd87164b6f541de6ed420b70ac67e055774:+8cjww1QbyXNX+PSKeCwmnysv0rAoEaKh409VWQJpDbEy/pPZCT6c0rKxLzvyMiSq6EwkOiduZMzokRgCKP7RuRqNPJceWsxNnpIUwa40KR1IP2tdreW4J8v9pFEzPMec1oq40u+c+UI/Y6ChOLV/4ozyWmpQCK3y8Ugm7B1/FzaeDs9Ie6Mvf98+XECoi0fWv9SO2TeBvq1G24LXd4zf0j8jd0sKZbLPXH0+gaUXtBH7A56lHvB0ED9NuiHI8xopTBd9ogKlz/b5+JB4zA2zQCQ3WMEE1qz6WeB2S4FMomgeO1e3trabdU0ICu0WMvDVii4qNlQo/inD41oHXKeV5QwnYoGjPrLJIaP0hiLKhDURTHygCdvWdp63OWI+aGxv0/HI+nfcRsqSE+aYECrWB/kp/c5yTrEqBEafuWZkw==:eyJrcCI6eyJpZGVudGlmaWVyIjoicGxheWdyb3VuZEBiaXRhci5pbyIsInB3X25vbmNlIjoiNjUxYWUxZWM5NTgwMzM5YTM1NjdlZTdmMGY4NjcyNDkyZGUyYzE2NmE1NTZjMTNkMTE5NzI4YTAzYzYwZjc5MyIsInZlcnNpb24iOiIwMDQiLCJvcmlnaW5hdGlvbiI6InByb3RvY29sLXVwZ3JhZGUiLCJjcmVhdGVkIjoiMTYxNDc4NDE5MjQ5NyJ9LCJ1IjoiMTAwNTFiZTctNGNhMi00YWYzLWFhZTktMDIxOTM5ZGY0ZmFiIiwidiI6IjAwNCJ9',
content_type: 'SN|ItemsKey',
enc_item_key:
'004:d25deb224251b4705a44d8ce125a62f6a2f0e0e856603e8f:FEv1pfU/VfY7XhJrTfpcdhaSBfmNySTQtHohFYDm8V84KlyF5YaXRKV7BfXsa77DKTjOCU/EHHsWwhBEEfsNnzNySHxTHNc26bpoz0V8h50=:eyJrcCI6eyJpZGVudGlmaWVyIjoicGxheWdyb3VuZEBiaXRhci5pbyIsInB3X25vbmNlIjoiNjUxYWUxZWM5NTgwMzM5YTM1NjdlZTdmMGY4NjcyNDkyZGUyYzE2NmE1NTZjMTNkMTE5NzI4YTAzYzYwZjc5MyIsInZlcnNpb24iOiIwMDQiLCJvcmlnaW5hdGlvbiI6InByb3RvY29sLXVwZ3JhZGUiLCJjcmVhdGVkIjoiMTYxNDc4NDE5MjQ5NyJ9LCJ1IjoiMTAwNTFiZTctNGNhMi00YWYzLWFhZTktMDIxOTM5ZGY0ZmFiIiwidiI6IjAwNCJ9',
auth_hash: null,
created_at: '2020-09-07T12:22:06.562000Z',
updated_at: '2021-03-03T15:09:55.741107Z',
deleted: false,
},
],
auth_params: {
identifier: 'playground@bitar.io',
pw_nonce: '651ae1ec9580339a3567ee7f0f8672492de2c166a556c13d119728a03c60f793',
version: '004',
},
}
const password = 'password'
application = await Factory.createInitAppWithRealCrypto()
Factory.handlePasswordChallenges(application, password)
const result = await application.mutator.importData(backupData, true)
expect(result).to.not.be.undefined
expect(result.affectedItems.length).to.be.eq(backupData.items.length)
expect(result.errorCount).to.be.eq(0)
})
it('should import data from 004 encrypted payload', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
const noteItem = await application.itemManager.createItem(ContentType.Note, {
title: 'Encrypted note',
text: 'On protocol version 004.',
})
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
await Factory.safeDeinit(application)
application = await Factory.createInitAppWithFakeCrypto()
Factory.handlePasswordChallenges(application, password)
const result = await application.mutator.importData(backupData, true)
expect(result).to.not.be.undefined
expect(result.affectedItems.length).to.be.eq(backupData.items.length)
expect(result.errorCount).to.be.eq(0)
const decryptedNote = application.itemManager.findItem(noteItem.uuid)
expect(decryptedNote.title).to.be.eq('Encrypted note')
expect(decryptedNote.text).to.be.eq('On protocol version 004.')
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
})
it('should return correct errorCount', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
const noteItem = await application.itemManager.createItem(ContentType.Note, {
title: 'This is a valid, encrypted note',
text: 'On protocol version 004.',
})
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
await Factory.safeDeinit(application)
application = await Factory.createInitAppWithFakeCrypto()
Factory.handlePasswordChallenges(application, password)
const madeUpPayload = JSON.parse(JSON.stringify(noteItem))
madeUpPayload.items_key_id = undefined
madeUpPayload.content = '004:somenonsense'
madeUpPayload.enc_item_key = '003:anothernonsense'
madeUpPayload.version = '004'
madeUpPayload.uuid = 'fake-uuid'
backupData.items = [...backupData.items, madeUpPayload]
const result = await application.mutator.importData(backupData, true)
expect(result).to.not.be.undefined
expect(result.affectedItems.length).to.be.eq(backupData.items.length - 1)
expect(result.errorCount).to.be.eq(1)
})
it('should not import data from 003 encrypted payload if an invalid password is provided', async function () {
await setup({ fakeCrypto: true })
const oldVersion = ProtocolVersion.V003
await Factory.registerOldUser({
application: application,
email: email,
password: UuidGenerator.GenerateUuid(),
version: oldVersion,
})
await application.itemManager.createItem(ContentType.Note, {
title: 'Encrypted note',
text: 'On protocol version 003.',
})
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
await Factory.safeDeinit(application)
application = await Factory.createInitAppWithFakeCrypto()
application.setLaunchCallback({
receiveChallenge: (challenge) => {
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.None ? 'incorrect password' : password,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
const result = await application.mutator.importData(backupData, true)
expect(result).to.not.be.undefined
expect(result.affectedItems.length).to.be.eq(0)
expect(result.errorCount).to.be.eq(backupData.items.length)
expect(application.itemManager.getDisplayableNotes().length).to.equal(0)
})
it('should not import data from 004 encrypted payload if an invalid password is provided', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
await application.itemManager.createItem(ContentType.Note, {
title: 'This is a valid, encrypted note',
text: 'On protocol version 004.',
})
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
await Factory.safeDeinit(application)
application = await Factory.createInitAppWithFakeCrypto()
application.setLaunchCallback({
receiveChallenge: (challenge) => {
const values = challenge.prompts.map((prompt) => CreateChallengeValue(prompt, 'incorrect password'))
application.submitValuesForChallenge(challenge, values)
},
})
const result = await application.mutator.importData(backupData, true)
expect(result).to.not.be.undefined
expect(result.affectedItems.length).to.be.eq(0)
expect(result.errorCount).to.be.eq(backupData.items.length)
expect(application.itemManager.getDisplayableNotes().length).to.equal(0)
})
it('should not import encrypted data with no keyParams or auth_params', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
await application.itemManager.createItem(ContentType.Note, {
title: 'Encrypted note',
text: 'On protocol version 004.',
})
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
delete backupData.keyParams
await Factory.safeDeinit(application)
application = await Factory.createInitAppWithFakeCrypto()
const result = await application.mutator.importData(backupData)
expect(result.error).to.be.ok
})
it('should not import payloads if the corresponding ItemsKey is not present within the backup file', async function () {
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
Factory.handlePasswordChallenges(application, password)
await application.itemManager.createItem(ContentType.Note, {
title: 'Encrypted note',
text: 'On protocol version 004.',
})
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
backupData.items = backupData.items.filter((payload) => payload.content_type !== ContentType.ItemsKey)
await Factory.safeDeinit(application)
application = await Factory.createInitAppWithFakeCrypto()
Factory.handlePasswordChallenges(application, password)
const result = await application.mutator.importData(backupData, true)
expect(result).to.not.be.undefined
expect(result.affectedItems.length).to.be.eq(0)
expect(result.errorCount).to.be.eq(backupData.items.length)
expect(application.itemManager.getDisplayableNotes().length).to.equal(0)
})
it('importing data with no items key should use the root key generated by the file password', async function () {
await setup({ fakeCrypto: false })
/**
* In SNJS 2.0.12, this file import would fail with "incorrect password" on file.
* The reason was that we would use the default items key we had for the current account
* instead of using the password generated root key for the file.
*
* Note this test will not be able to properly sync as the credentials are invalid.
* This test is only meant to test successful local importing.
*/
const identifier = 'standardnotes'
const application = await Factory.createApplicationWithRealCrypto(identifier)
/** Create legacy migrations value so that base migration detects old app */
await application.deviceInterface.setRawStorageValue(
'keychain',
JSON.stringify({
[identifier]: {
version: '003',
masterKey: '30bae65687b45b20100be219df983bded23868baa44f4bbef1026403daee0a9d',
dataAuthenticationKey: 'c9b382ff1f7adb5c6cad620605ad139cd9f1e7700f507345ef1a1d46a6413712',
},
}),
)
await application.deviceInterface.setRawStorageValue(
'descriptors',
JSON.stringify({
[identifier]: {
identifier: 'standardnotes',
label: 'Main Application',
primary: true,
},
}),
)
await application.deviceInterface.setRawStorageValue('standardnotes-snjs_version', '2.0.11')
await application.deviceInterface.saveRawDatabasePayload(
{
content:
'003:9f2c7527eb8b2a1f8bfb3ea6b885403b6886bce2640843ebd57a6c479cbf7597:58e3322b-269a-4be3-a658-b035dffcd70f:9140b23a0fa989e224e292049f133154:SESTNOgIGf2+ZqmJdFnGU4EMgQkhKOzpZNoSzx76SJaImsayzctAgbUmJ+UU2gSQAHADS3+Z5w11bXvZgIrStTsWriwvYkNyyKmUPadKHNSBwOk4WeBZpWsA9gtI5zgI04Q5pvb8hS+kNW2j1DjM4YWqd0JQxMOeOrMIrxr/6Awn5TzYE+9wCbXZdYHyvRQcp9ui/G02ZJ67IA86vNEdjTTBAAWipWqTqKH9VDZbSQ2W/IOKfIquB373SFDKZb1S1NmBFvcoG2G7w//fAl/+ehYiL6UdiNH5MhXCDAOTQRFNfOh57HFDWVnz1VIp8X+VAPy6d9zzQH+8aws1JxHq/7BOhXrFE8UCueV6kERt9njgQxKJzd9AH32ShSiUB9X/sPi0fUXbS178xAZMJrNx3w==:eyJwd19ub25jZSI6IjRjYjEwM2FhODljZmY0NTYzYTkxMWQzZjM5NjU4M2NlZmM2ODMzYzY2Zjg4MGZiZWUwNmJkYTk0YzMxZjg2OGIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzIyQGJpdGFyLmlvIiwidmVyc2lvbiI6IjAwMyIsIm9yaWdpbmF0aW9uIjoicmVnaXN0cmF0aW9uIn0=',
content_type: 'SN|ItemsKey',
created_at: new Date(),
enc_item_key:
'003:d7267919b07864ccc1da87a48db6c6192e2e892be29ce882e981c36f673b3847:58e3322b-269a-4be3-a658-b035dffcd70f:2384a22d8f8bf671ba6517c6e1d0be30:0qXjBDPLCcMlNTnuUDcFiJPIXU9OP6b4ttTVE58n2Jn7971xMhx6toLbAZWWLPk/ezX/19EYE9xmRngWsG4jJaZMxGZIz/melU08K7AHH3oahQpHwZvSM3iV2ufsN7liQywftdVH6NNzULnZnFX+FgEfpDquru++R4aWDLvsSegWYmde9zD62pPNUB9Kik6P:eyJwd19ub25jZSI6IjRjYjEwM2FhODljZmY0NTYzYTkxMWQzZjM5NjU4M2NlZmM2ODMzYzY2Zjg4MGZiZWUwNmJkYTk0YzMxZjg2OGIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzIyQGJpdGFyLmlvIiwidmVyc2lvbiI6IjAwMyIsIm9yaWdpbmF0aW9uIjoicmVnaXN0cmF0aW9uIn0=',
updated_at: new Date(),
uuid: '58e3322b-269a-4be3-a658-b035dffcd70f',
},
identifier,
)
/**
* Note that this storage contains "sync.standardnotes.org" as the API Host param.
*/
await application.deviceInterface.setRawStorageValue(
'standardnotes-storage',
JSON.stringify({
wrapped: {
uuid: '15af096f-4e9d-4cde-8d67-f132218fa757',
content_type: 'SN|EncryptedStorage',
enc_item_key:
'003:2fb0c55859ddf0c16982b91d6202a6fb8174f711d820f8b785c558538cda5048:15af096f-4e9d-4cde-8d67-f132218fa757:09a4da52d5214e76642f0363246daa99:zt5fnmxYSZOqC+uA08oAKdtjfTdAoX1lPnbTe98CYQSlIvaePIpG5c9tAN5QzZbECkj4Lm9txwSA2O6Y4Y25rqO4lIerKjxxNqPwDze9mtPOGeoR48csUPiMIHiH78bLGZZs4VoBwYKAP+uEygXEFYRuscGnDOrFV7fnwGDL/nkhr6xpM159OTUKBgiBpVMS:eyJwd19ub25jZSI6IjRjYjEwM2FhODljZmY0NTYzYTkxMWQzZjM5NjU4M2NlZmM2ODMzYzY2Zjg4MGZiZWUwNmJkYTk0YzMxZjg2OGIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzIyQGJpdGFyLmlvIiwidmVyc2lvbiI6IjAwMyIsIm9yaWdpbmF0aW9uIjoicmVnaXN0cmF0aW9uIn0=',
content:
'003:70a02948696e09211cfd34cd312dbbf85751397189da06d7acc7c46dafa9aeeb:15af096f-4e9d-4cde-8d67-f132218fa757:b92fb4b030ac51f4d3eef0ada35f3d5f:r3gdrawyd069qOQQotD5EtabTwjs4IiLFWotK0Ygbt9oAT09xILx7v92z8YALJ6i6EKHOT7zyCytR5l2B9b1J7Tls00uVgfEKs3zX7n3F6ne+ju0++WsJuy0Gre5+Olov6lqQrY3I8hWQShxaG84huZaFTIPU5+LP0JAseWWDENqUQ+Vxr+w0wqNYO6TLtr/YAqk2yOY7DLQ0WhGzK+WH9JfvS8MCccJVeBD99ebM8lKVVfTaUfrk2AlbMv47TFSjTeCDblQuU68joE45HV8Y0g2CF4nkTvdr3wn0HhdDp07YuXditX9NGtBhI8oFkstwKEksblyX9dGpn7of4ctdvNOom3Vjw/m4x9mE0lCIbjxQVAiDyy+Hg0HDtVt1j205ycg1RS7cT7+Sn746Z06S8TixcVUUUQh+MGRIulIE5utOE81Lv/p+jb2vmv+TGHUV4kZJPluG7A9IEphMZrMWwiU56FdSlSDD82qd9iG+C3Pux+X/GYCMiWS2T/BoyI6a9OERSARuTUuom2bv59hqD1yUoj7VQXhqXmverSwLE1zDeF+dc0tMwuTNCNOTk08A6wRKTR9ZjuFlLcxHsg/VZyfIdCkElFh1FrliMbW2ZsgsPFaZAI+YN8pid1tTw+Ou1cOfyD85aki98DDvg/cTi8ahrrm8UvxRQwhIW17Cm1RnKxhIvaq5HRjEN76Y46ubkZv7/HjhNwJt9vPEr9wyOrMH6XSxCnSIFD1kbVHI33q444xyUWa/EQju8SoEGGU92HhpMWd1kIz37SJRJTC7u2ah2Xg60JGcUcCNtHG3IHMPVP+UKUjx5nKP6t/NVSa+xsjIvM/ZkSL37W0TMZykC1cKfzeUmlZhGQPCIqad3b4ognZ48LGCgwBP87rWn8Ln8Cqcz7X0Ze22HoouKBPAtWlYJ8fmvg2HiW6nX/L9DqoxK4OXt/LnC2BTEvtP4PUzBqx8WoqmVNNnYp+FgYptLcgxmgckle41w1eMr6NYGeaaC1Jk3i/e9Piw0w0XjV/lB+yn03gEMYPTT2yiXMQrfPmkUNYNN7/xfhY3bqqwfER7iXdr/80Lc+x9byywChXLvg8VCjHWGd+Sky3NHyMdxLY8IqefyyZWMeXtt1aNYH6QW9DeK5KvK3DI+MK3kWwMCySe51lkE9jzcqrxpYMZjb2Za9VDZNBgdwQYXfOlxFEje0so0LlMJmmxRfbMU06bYt0vszT2szAkOnVuyi6TBRiGLyjMxYI0csM0SHZWZUQK0z7ZoQAWR5D+adX29tOvrKc2kJA8Lrzgeqw/rJIh6zPg3kmsd2rFbo+Qfe3J6XrlZU+J+N96I98i0FU0quI6HwG1zFg6UOmfRjaCML8rSAPtMaNhlO7M2sgRmDCtsNcpU06Fua6F2fEHPiXs4+9:eyJwd19ub25jZSI6IjRjYjEwM2FhODljZmY0NTYzYTkxMWQzZjM5NjU4M2NlZmM2ODMzYzY2Zjg4MGZiZWUwNmJkYTk0YzMxZjg2OGIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzIyQGJpdGFyLmlvIiwidmVyc2lvbiI6IjAwMyIsIm9yaWdpbmF0aW9uIjoicmVnaXN0cmF0aW9uIn0=',
created_at: '2020-11-24T00:53:42.057Z',
updated_at: '1970-01-01T00:00:00.000Z',
},
nonwrapped: {
ROOT_KEY_PARAMS: {
pw_nonce: '4cb103aa89cff4563a911d3f396583cefc6833c66f880fbee06bda94c31f868b',
pw_cost: 110000,
identifier: 'nov2322@bitar.io',
version: '003',
},
},
}),
)
const password = 'password'
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
if (challenge.prompts.length === 2) {
application.submitValuesForChallenge(
challenge,
challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation !== ChallengeValidation.ProtectionSessionDuration
? password
: UnprotectedAccessSecondsDuration.OneMinute,
),
),
)
} else {
const prompt = challenge.prompts[0]
application.submitValuesForChallenge(challenge, [CreateChallengeValue(prompt, password)])
}
},
})
await application.launch(false)
await application.setHost(Factory.getDefaultHost())
const backupFile = {
items: [
{
uuid: '11204d02-5a8b-47c0-ab94-ae0727d656b5',
content_type: 'Note',
created_at: '2020-11-23T17:11:06.322Z',
enc_item_key:
'003:111edcff9ed3432b9e11c4a64bef9e810ed2b9147790963caf6886511c46bbc4:11204d02-5a8b-47c0-ab94-ae0727d656b5:62de2b95cca4d7948f70516d12f5cb3a:lhUF/EoQP2DC8CSVrXyLp1yXsiJUXxwmtkwXtLUJ5sm4E0+ZNzMCO9U9ho+q6i9V+777dSbfTqODz4ZSt6hj3gtYxi9ZlOM/VrTtmJ2YcxiMaRTVl5sVZPG+YTpQPMuugN5/0EfuT/SJ9IqVbjgYhKA5xt/lMgw4JSbiW8ZkVQ5tVDfgt0omhDRLlkh758ou:eyJwd19ub25jZSI6IjNlMzU3YzQxZmI1YWU2MTUyYmZmMzY2ZjBhOGE3ZjRmZDk2NDQxZDZhNWViYzY3MDA4OTk2ZWY2YzU1YTg3ZjIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzVAYml0YXIuaW8iLCJ2ZXJzaW9uIjoiMDAzIn0=',
content:
'003:d43c6d2dc9465796e01145843cf1b95031030c15cc79a73f14d941d15e28147a:11204d02-5a8b-47c0-ab94-ae0727d656b5:84a2b760019a62d7ad9c314bc7a5564a:G8Mm9fy9ybuo92VbV4NUERruJ1VA7garv1+fBg4KRDRjsRGoLvORhHldQHRfUQmSR6PkrG6ol/jOn1gjIH5gtgGczB5NgbKau7amYZHsQJPr1UleJVsLrjMJgiYGqbEDmXPtJSX2tLGFhAbYcVX4xrHKbkiuLQnu9bZp9zbR6txB1NtLoNFvwDZTMko7Q+28fM4TKBbQCCw3NufLHVUnfEwS7tLLFFPdEyyMXOerKP93u8X+7NG2eDmsUetPsPOq:eyJwd19ub25jZSI6IjNlMzU3YzQxZmI1YWU2MTUyYmZmMzY2ZjBhOGE3ZjRmZDk2NDQxZDZhNWViYzY3MDA4OTk2ZWY2YzU1YTg3ZjIiLCJwd19jb3N0IjoxMTAwMDAsImlkZW50aWZpZXIiOiJub3YyMzVAYml0YXIuaW8iLCJ2ZXJzaW9uIjoiMDAzIn0=',
auth_hash: null,
updated_at: '2020-11-23T17:11:40.399Z',
},
],
auth_params: {
pw_nonce: '3e357c41fb5ae6152bff366f0a8a7f4fd96441d6a5ebc67008996ef6c55a87f2',
pw_cost: 110000,
identifier: 'nov235@bitar.io',
version: '003',
},
}
const result = await application.mutator.importData(backupFile, false)
expect(result.errorCount).to.equal(0)
await Factory.safeDeinit(application)
})
it('importing another accounts notes/tags should correctly keep relationships', async function () {
this.timeout(Factory.TwentySecondTimeout)
await setup({ fakeCrypto: true })
await Factory.registerUserToApplication({
application: application,
email: email,
password: password,
})
Factory.handlePasswordChallenges(application, password)
const pair = createRelatedNoteTagPairPayload()
await application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged)
await application.sync.sync()
const backupData = await application.createEncryptedBackupFileForAutomatedDesktopBackups()
await Factory.safeDeinit(application)
application = await Factory.createInitAppWithFakeCrypto()
Factory.handlePasswordChallenges(application, password)
await Factory.registerUserToApplication({
application: application,
email: `${Math.random()}`,
password: password,
})
await application.mutator.importData(backupData, true)
expect(application.itemManager.getDisplayableNotes().length).to.equal(1)
expect(application.itemManager.getDisplayableTags().length).to.equal(1)
const importedNote = application.itemManager.getDisplayableNotes()[0]
const importedTag = application.itemManager.getDisplayableTags()[0]
expect(application.itemManager.referencesForItem(importedTag).length).to.equal(1)
expect(application.itemManager.itemsReferencingItem(importedNote).length).to.equal(1)
})
})

View File

@@ -0,0 +1,220 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('items', () => {
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
beforeEach(async function () {
this.expectedItemCount = BASE_ITEM_COUNT
this.application = await Factory.createInitAppWithFakeCrypto()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
})
it('setting an item as dirty should update its client updated at', async function () {
const params = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged)
const item = this.application.itemManager.items[0]
const prevDate = item.userModifiedDate.getTime()
await Factory.sleep(0.1)
await this.application.itemManager.setItemDirty(item, true)
const refreshedItem = this.application.itemManager.findItem(item.uuid)
const newDate = refreshedItem.userModifiedDate.getTime()
expect(prevDate).to.not.equal(newDate)
})
it('setting an item as dirty with option to skip client updated at', async function () {
const params = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged)
const item = this.application.itemManager.items[0]
const prevDate = item.userModifiedDate.getTime()
await Factory.sleep(0.1)
await this.application.itemManager.setItemDirty(item)
const newDate = item.userModifiedDate.getTime()
expect(prevDate).to.equal(newDate)
})
it('properly pins, archives, and locks', async function () {
const params = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([params], PayloadEmitSource.LocalChanged)
const item = this.application.itemManager.items[0]
expect(item.pinned).to.not.be.ok
const refreshedItem = await this.application.mutator.changeAndSaveItem(
item,
(mutator) => {
mutator.pinned = true
mutator.archived = true
mutator.locked = true
},
undefined,
undefined,
syncOptions,
)
expect(refreshedItem.pinned).to.equal(true)
expect(refreshedItem.archived).to.equal(true)
expect(refreshedItem.locked).to.equal(true)
})
it('properly compares item equality', async function () {
const params1 = Factory.createNotePayload()
const params2 = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([params1, params2], PayloadEmitSource.LocalChanged)
let item1 = this.application.itemManager.getDisplayableNotes()[0]
let item2 = this.application.itemManager.getDisplayableNotes()[1]
expect(item1.isItemContentEqualWith(item2)).to.equal(true)
// items should ignore this field when checking for equality
item1 = await this.application.mutator.changeAndSaveItem(
item1,
(mutator) => {
mutator.userModifiedDate = new Date()
},
undefined,
undefined,
syncOptions,
)
item2 = await this.application.mutator.changeAndSaveItem(
item2,
(mutator) => {
mutator.userModifiedDate = undefined
},
undefined,
undefined,
syncOptions,
)
expect(item1.isItemContentEqualWith(item2)).to.equal(true)
item1 = await this.application.mutator.changeAndSaveItem(
item1,
(mutator) => {
mutator.mutableContent.foo = 'bar'
},
undefined,
undefined,
syncOptions,
)
expect(item1.isItemContentEqualWith(item2)).to.equal(false)
item2 = await this.application.mutator.changeAndSaveItem(
item2,
(mutator) => {
mutator.mutableContent.foo = 'bar'
},
undefined,
undefined,
syncOptions,
)
expect(item1.isItemContentEqualWith(item2)).to.equal(true)
expect(item2.isItemContentEqualWith(item1)).to.equal(true)
item1 = await this.application.mutator.changeAndSaveItem(
item1,
(mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item2)
},
undefined,
undefined,
syncOptions,
)
item2 = await this.application.mutator.changeAndSaveItem(
item2,
(mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(item1)
},
undefined,
undefined,
syncOptions,
)
expect(item1.content.references.length).to.equal(1)
expect(item2.content.references.length).to.equal(1)
expect(item1.isItemContentEqualWith(item2)).to.equal(false)
item1 = await this.application.mutator.changeAndSaveItem(
item1,
(mutator) => {
mutator.removeItemAsRelationship(item2)
},
undefined,
undefined,
syncOptions,
)
item2 = await this.application.mutator.changeAndSaveItem(
item2,
(mutator) => {
mutator.removeItemAsRelationship(item1)
},
undefined,
undefined,
syncOptions,
)
expect(item1.isItemContentEqualWith(item2)).to.equal(true)
expect(item1.content.references.length).to.equal(0)
expect(item2.content.references.length).to.equal(0)
})
it('content equality should not have side effects', async function () {
const params1 = Factory.createNotePayload()
const params2 = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([params1, params2], PayloadEmitSource.LocalChanged)
let item1 = this.application.itemManager.getDisplayableNotes()[0]
const item2 = this.application.itemManager.getDisplayableNotes()[1]
item1 = await this.application.mutator.changeAndSaveItem(
item1,
(mutator) => {
mutator.mutableContent.foo = 'bar'
},
undefined,
undefined,
syncOptions,
)
expect(item1.content.foo).to.equal('bar')
item1.contentKeysToIgnoreWhenCheckingEquality = () => {
return ['foo']
}
item2.contentKeysToIgnoreWhenCheckingEquality = () => {
return ['foo']
}
// calling isItemContentEqualWith should not have side effects
// There was an issue where calling that function would modify values directly to omit keys
// in contentKeysToIgnoreWhenCheckingEquality.
await this.application.itemManager.setItemsDirty([item1, item2])
expect(item1.userModifiedDate).to.be.ok
expect(item2.userModifiedDate).to.be.ok
expect(item1.isItemContentEqualWith(item2)).to.equal(true)
expect(item2.isItemContentEqualWith(item1)).to.equal(true)
expect(item1.userModifiedDate).to.be.ok
expect(item2.userModifiedDate).to.be.ok
expect(item1.content.foo).to.equal('bar')
})
})

View File

@@ -0,0 +1,127 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
import { createNoteParams } from '../lib/Items.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('model manager mapping', () => {
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
beforeEach(async function () {
this.expectedItemCount = BASE_ITEM_COUNT
this.application = await Factory.createInitAppWithFakeCrypto()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
})
it('mapping nonexistent item creates it', async function () {
const payload = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged)
this.expectedItemCount++
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
})
it('mapping nonexistent deleted item doesnt create it', async function () {
const payload = new DeletedPayload({
...createNoteParams(),
dirty: false,
deleted: true,
})
await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
})
it('mapping and deleting nonexistent item creates and deletes it', async function () {
const payload = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged)
this.expectedItemCount++
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
const changedParams = new DeletedPayload({
...payload,
dirty: false,
deleted: true,
})
this.expectedItemCount--
await this.application.itemManager.emitItemsFromPayloads([changedParams], PayloadEmitSource.LocalChanged)
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
})
it('mapping deleted but dirty item should not delete it', async function () {
const payload = Factory.createNotePayload()
const [item] = await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged)
this.expectedItemCount++
await this.application.itemManager.emitItemFromPayload(new DeleteItemMutator(item).getDeletedResult())
const payload2 = new DeletedPayload(this.application.payloadManager.findOne(payload.uuid).ejected())
await this.application.itemManager.emitItemsFromPayloads([payload2], PayloadEmitSource.LocalChanged)
expect(this.application.payloadManager.collection.all().length).to.equal(this.expectedItemCount)
})
it('mapping existing item updates its properties', async function () {
const payload = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged)
const newTitle = 'updated title'
const mutated = new DecryptedPayload({
...payload,
content: {
...payload.content,
title: newTitle,
},
})
await this.application.itemManager.emitItemsFromPayloads([mutated], PayloadEmitSource.LocalChanged)
const item = this.application.itemManager.getDisplayableNotes()[0]
expect(item.content.title).to.equal(newTitle)
})
it('setting an item dirty should retrieve it in dirty items', async function () {
const payload = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged)
const note = this.application.itemManager.getDisplayableNotes()[0]
await this.application.itemManager.setItemDirty(note)
const dirtyItems = this.application.itemManager.getDirtyItems()
expect(dirtyItems.length).to.equal(1)
})
it('set all items dirty', async function () {
const count = 10
this.expectedItemCount += count
const payloads = []
for (let i = 0; i < count; i++) {
payloads.push(Factory.createNotePayload())
}
await this.application.itemManager.emitItemsFromPayloads(payloads, PayloadEmitSource.LocalChanged)
await this.application.syncService.markAllItemsAsNeedingSyncAndPersist()
const dirtyItems = this.application.itemManager.getDirtyItems()
expect(dirtyItems.length).to.equal(this.expectedItemCount)
})
it('sync observers should be notified of changes', async function () {
const payload = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged)
const item = this.application.itemManager.items[0]
return new Promise((resolve) => {
this.application.itemManager.addObserver(ContentType.Any, ({ changed }) => {
expect(changed[0].uuid === item.uuid)
resolve()
})
this.application.itemManager.emitItemsFromPayloads([payload], PayloadEmitSource.LocalChanged)
})
})
})

View File

@@ -0,0 +1,75 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
const generateLongString = (minLength = 600) => {
const BASE = 'Lorem ipsum dolor sit amet. '
const repeatCount = Math.ceil(minLength / BASE.length)
return BASE.repeat(repeatCount)
}
const getFilteredNotes = (application, { views }) => {
const criteria = {
views,
includePinned: true,
}
application.items.setPrimaryItemDisplayOptions(criteria)
const notes = application.items.getDisplayableNotes()
return notes
}
const titles = (items) => {
return items.map((item) => item.title).sort()
}
describe('notes and smart views', () => {
beforeEach(async function () {
this.application = await Factory.createInitAppWithFakeCrypto()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
})
it('lets me create a smart view and use it', async function () {
// ## The user creates 3 notes
const [note_1, note_2, note_3] = await Promise.all([
Factory.createMappedNote(this.application, 'long & pinned', generateLongString()),
Factory.createMappedNote(this.application, 'long & !pinned', generateLongString()),
Factory.createMappedNote(this.application, 'pinned', 'this is a pinned note'),
])
// The user pin 2 notes
await Promise.all([Factory.pinNote(this.application, note_1), Factory.pinNote(this.application, note_3)])
// ## The user creates smart views (long & pinned)
const not_pinned = '!["Not Pinned", "pinned", "=", false]'
const long = '!["Long", "text.length", ">", 500]'
const tag_not_pinned = await this.application.mutator.createTagOrSmartView(not_pinned)
const tag_long = await this.application.mutator.createTagOrSmartView(long)
// ## The user can filter and see the pinned notes
const notes_not_pinned = getFilteredNotes(this.application, {
views: [tag_not_pinned],
})
expect(titles(notes_not_pinned)).to.eql(['long & !pinned'])
// ## The user can filter and see the long notes
const notes_long = getFilteredNotes(this.application, { views: [tag_long] })
expect(titles(notes_long)).to.eql(['long & !pinned', 'long & pinned'])
// ## The user creates a new long note
await Factory.createMappedNote(this.application, 'new long', generateLongString())
// ## The user can filter and see the new long note
const notes_long2 = getFilteredNotes(this.application, {
views: [tag_long],
})
expect(titles(notes_long2)).to.eql(['long & !pinned', 'long & pinned', 'new long'])
})
})

View File

@@ -0,0 +1,846 @@
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
import * as Utils from '../lib/Utils.js'
import { createRelatedNoteTagPairPayload } from '../lib/Items.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('notes and tags', () => {
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
beforeEach(async function () {
this.expectedItemCount = BASE_ITEM_COUNT
this.application = await Factory.createInitAppWithFakeCrypto()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
})
it('uses proper class for note', async function () {
const payload = Factory.createNotePayload()
await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
const note = this.application.itemManager.getItems([ContentType.Note])[0]
expect(note.constructor === SNNote).to.equal(true)
})
it('properly constructs syncing params', async function () {
const title = 'Foo'
const text = 'Bar'
const note = await this.application.mutator.createTemplateItem(ContentType.Note, {
title,
text,
})
expect(note.content.title).to.equal(title)
expect(note.content.text).to.equal(text)
const tag = await this.application.mutator.createTemplateItem(ContentType.Tag, {
title,
})
expect(tag.title).to.equal(title)
})
it('properly handles legacy relationships', async function () {
// legacy relationships are when a note has a reference to a tag
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
const mutatedTag = new DecryptedPayload({
...tagPayload,
content: {
...tagPayload.content,
references: null,
},
})
const mutatedNote = new DecryptedPayload({
...notePayload,
content: {
references: [
{
uuid: tagPayload.uuid,
content_type: tagPayload.content_type,
},
],
},
})
await this.application.itemManager.emitItemsFromPayloads([mutatedNote, mutatedTag], PayloadEmitSource.LocalChanged)
const note = this.application.itemManager.getItems([ContentType.Note])[0]
const tag = this.application.itemManager.getItems([ContentType.Tag])[0]
expect(note.content.references.length).to.equal(1)
expect(this.application.itemManager.itemsReferencingItem(tag).length).to.equal(1)
})
it('creates relationship between note and tag', async function () {
const pair = createRelatedNoteTagPairPayload({ dirty: false })
const notePayload = pair[0]
const tagPayload = pair[1]
expect(notePayload.content.references.length).to.equal(0)
expect(tagPayload.content.references.length).to.equal(1)
await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged)
let note = this.application.itemManager.getDisplayableNotes()[0]
let tag = this.application.itemManager.getDisplayableTags()[0]
expect(note.dirty).to.not.be.ok
expect(tag.dirty).to.not.be.ok
expect(note.content.references.length).to.equal(0)
expect(tag.content.references.length).to.equal(1)
expect(note.isReferencingItem(tag)).to.equal(false)
expect(tag.isReferencingItem(note)).to.equal(true)
expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1)
expect(note.payload.references.length).to.equal(0)
expect(tag.noteCount).to.equal(1)
await this.application.itemManager.setItemToBeDeleted(note)
tag = this.application.itemManager.getDisplayableTags()[0]
const deletedNotePayload = this.application.payloadManager.findOne(note.uuid)
expect(deletedNotePayload.dirty).to.be.true
expect(tag.dirty).to.be.true
await this.application.syncService.sync(syncOptions)
expect(tag.content.references.length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(0)
expect(tag.noteCount).to.equal(0)
tag = this.application.itemManager.getDisplayableTags()[0]
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(0)
expect(tag.dirty).to.be.false
})
it('handles remote deletion of relationship', async function () {
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
await this.application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged)
let note = this.application.itemManager.getItems([ContentType.Note])[0]
let tag = this.application.itemManager.getItems([ContentType.Tag])[0]
expect(note.content.references.length).to.equal(0)
expect(tag.content.references.length).to.equal(1)
await this.application.syncService.sync(syncOptions)
const mutatedTag = new DecryptedPayload({
...tagPayload,
dirty: false,
content: {
...tagPayload.content,
references: [],
},
})
await this.application.itemManager.emitItemsFromPayloads([mutatedTag], PayloadEmitSource.LocalChanged)
note = this.application.itemManager.findItem(note.uuid)
tag = this.application.itemManager.findItem(tag.uuid)
expect(tag.content.references.length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(0)
expect(tag.noteCount).to.equal(0)
// expect to be false
expect(note.dirty).to.not.be.ok
expect(tag.dirty).to.not.be.ok
})
it('creating basic note should have text set', async function () {
const note = await Factory.createMappedNote(this.application)
expect(note.title).to.be.ok
expect(note.text).to.be.ok
})
it('creating basic tag should have title', async function () {
const tag = await Factory.createMappedTag(this.application)
expect(tag.title).to.be.ok
})
it('handles removing relationship between note and tag', async function () {
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged)
const note = this.application.itemManager.getItems([ContentType.Note])[0]
let tag = this.application.itemManager.getItems([ContentType.Tag])[0]
expect(note.content.references.length).to.equal(0)
expect(tag.content.references.length).to.equal(1)
tag = await this.application.mutator.changeAndSaveItem(
tag,
(mutator) => {
mutator.removeItemAsRelationship(note)
},
undefined,
undefined,
syncOptions,
)
expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(0)
expect(tag.noteCount).to.equal(0)
})
it('properly handles tag duplication', async function () {
const pair = createRelatedNoteTagPairPayload()
await this.application.itemManager.emitItemsFromPayloads(pair, PayloadEmitSource.LocalChanged)
let note = this.application.itemManager.getDisplayableNotes()[0]
let tag = this.application.itemManager.getDisplayableTags()[0]
const duplicateTag = await this.application.itemManager.duplicateItem(tag, true)
await this.application.syncService.sync(syncOptions)
note = this.application.itemManager.findItem(note.uuid)
tag = this.application.itemManager.findItem(tag.uuid)
expect(tag.uuid).to.not.equal(duplicateTag.uuid)
expect(tag.content.references.length).to.equal(1)
expect(tag.noteCount).to.equal(1)
expect(duplicateTag.content.references.length).to.equal(1)
expect(duplicateTag.noteCount).to.equal(1)
const noteTags = this.application.itemManager.itemsReferencingItem(note)
expect(noteTags.length).to.equal(2)
const noteTag1 = noteTags[0]
const noteTag2 = noteTags[1]
expect(noteTag1.uuid).to.not.equal(noteTag2.uuid)
// expect to be false
expect(note.dirty).to.not.be.ok
expect(tag.dirty).to.not.be.ok
})
it('duplicating a note should maintain its tag references', async function () {
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged)
const note = this.application.itemManager.getItems([ContentType.Note])[0]
const duplicateNote = await this.application.itemManager.duplicateItem(note, true)
expect(note.uuid).to.not.equal(duplicateNote.uuid)
expect(this.application.itemManager.itemsReferencingItem(duplicateNote).length).to.equal(
this.application.itemManager.itemsReferencingItem(note).length,
)
})
it('deleting a note should update tag references', async function () {
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged)
const note = this.application.itemManager.getItems([ContentType.Note])[0]
let tag = this.application.itemManager.getItems([ContentType.Tag])[0]
expect(tag.content.references.length).to.equal(1)
expect(tag.noteCount).to.equal(1)
expect(note.content.references.length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1)
await this.application.itemManager.setItemToBeDeleted(tag)
tag = this.application.itemManager.findItem(tag.uuid)
expect(tag).to.not.be.ok
})
it('modifying item content should not modify payload content', async function () {
const notePayload = Factory.createNotePayload()
await this.application.itemManager.emitItemsFromPayloads([notePayload], PayloadEmitSource.LocalChanged)
let note = this.application.itemManager.getItems([ContentType.Note])[0]
note = await this.application.mutator.changeAndSaveItem(
note,
(mutator) => {
mutator.mutableContent.title = Math.random()
},
undefined,
undefined,
syncOptions,
)
expect(note.content.title).to.not.equal(notePayload.content.title)
})
it('deleting a tag should not dirty notes', async function () {
// Tags now reference notes, but it used to be that tags referenced notes and notes referenced tags.
// After the change, there was an issue where removing an old tag relationship from a note would only
// remove one way, and thus keep it intact on the visual level.
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged)
let note = this.application.itemManager.getItems([ContentType.Note])[0]
let tag = this.application.itemManager.getItems([ContentType.Tag])[0]
await this.application.syncService.sync(syncOptions)
await this.application.itemManager.setItemToBeDeleted(tag)
note = this.application.itemManager.findItem(note.uuid)
this.application.itemManager.findItem(tag.uuid)
expect(note.dirty).to.not.be.ok
})
it('should sort notes', async function () {
await Promise.all(
['Y', 'Z', 'A', 'B'].map(async (title) => {
return this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, { title }),
)
}),
)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'dsc',
})
const titles = this.application.items.getDisplayableNotes().map((note) => note.title)
/** setPrimaryItemDisplayOptions inverses sort for title */
expect(titles).to.deep.equal(['A', 'B', 'Y', 'Z'])
})
it('setting a note dirty should collapse its properties into content', async function () {
let note = await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'Foo',
})
await this.application.mutator.insertItem(note)
note = this.application.itemManager.findItem(note.uuid)
expect(note.content.title).to.equal('Foo')
})
describe('Tags', function () {
it('should sort tags in ascending alphabetical order by default', async function () {
const titles = ['1', 'A', 'b', '2']
const sortedTitles = titles.sort((a, b) => a.localeCompare(b))
await Promise.all(titles.map((title) => this.application.mutator.findOrCreateTag(title)))
expect(this.application.items.tagDisplayController.items().map((t) => t.title)).to.deep.equal(sortedTitles)
})
it('should match a tag', async function () {
const taggedNote = await Factory.createMappedNote(this.application)
const tag = await this.application.mutator.findOrCreateTag('A')
await this.application.mutator.changeItem(tag, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(taggedNote)
})
await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'dsc',
tags: [tag],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes.length).to.equal(1)
expect(displayedNotes[0].uuid).to.equal(taggedNote.uuid)
})
it('should not show trashed notes when displaying a tag', async function () {
const taggedNote = await Factory.createMappedNote(this.application)
const trashedNote = await Factory.createMappedNote(this.application)
const tag = await this.application.mutator.findOrCreateTag('A')
await this.application.mutator.changeItem(tag, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(taggedNote)
mutator.e2ePendingRefactor_addItemAsRelationship(trashedNote)
})
await this.application.mutator.changeItem(trashedNote, (mutator) => {
mutator.trashed = true
})
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'dsc',
tags: [tag],
includeTrashed: false,
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes.length).to.equal(1)
expect(displayedNotes[0].uuid).to.equal(taggedNote.uuid)
})
it('should sort notes when displaying tag', async function () {
await Promise.all(
['Y', 'Z', 'A', 'B'].map(async (title) => {
return this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title,
}),
)
}),
)
const pinnedNote = this.application.itemManager.getDisplayableNotes().find((note) => note.title === 'B')
await this.application.mutator.changeItem(pinnedNote, (mutator) => {
mutator.pinned = true
})
const tag = await this.application.mutator.findOrCreateTag('A')
await this.application.mutator.changeItem(tag, (mutator) => {
for (const note of this.application.itemManager.getDisplayableNotes()) {
mutator.e2ePendingRefactor_addItemAsRelationship(note)
}
})
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'dsc',
tags: [tag],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes).to.have.length(4)
/** setPrimaryItemDisplayOptions inverses sort for title */
expect(displayedNotes[0].title).to.equal('B')
expect(displayedNotes[1].title).to.equal('A')
})
})
describe('Smart views', function () {
it('"title", "startsWith", "Foo"', async function () {
const note = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'Foo 🎲',
}),
)
await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'Not Foo 🎲',
}),
)
const view = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.SmartView, {
title: 'Foo Notes',
predicate: {
keypath: 'title',
operator: 'startsWith',
value: 'Foo',
},
}),
)
const matches = this.application.items.notesMatchingSmartView(view)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'asc',
views: [view],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes).to.deep.equal(matches)
expect(matches.length).to.equal(1)
expect(matches[0].uuid).to.equal(note.uuid)
})
it('"pinned", "=", true', async function () {
const note = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
await this.application.mutator.changeItem(note, (mutator) => {
mutator.pinned = true
})
await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'B',
pinned: false,
}),
)
const view = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.SmartView, {
title: 'Pinned',
predicate: {
keypath: 'pinned',
operator: '=',
value: true,
},
}),
)
const matches = this.application.items.notesMatchingSmartView(view)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'asc',
views: [view],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes).to.deep.equal(matches)
expect(matches.length).to.equal(1)
expect(matches[0].uuid).to.equal(note.uuid)
})
it('"pinned", "=", false', async function () {
const pinnedNote = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
await this.application.mutator.changeItem(pinnedNote, (mutator) => {
mutator.pinned = true
})
const unpinnedNote = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'B',
}),
)
const view = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.SmartView, {
title: 'Not pinned',
predicate: {
keypath: 'pinned',
operator: '=',
value: false,
},
}),
)
const matches = this.application.items.notesMatchingSmartView(view)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'asc',
views: [view],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes).to.deep.equal(matches)
expect(matches.length).to.equal(1)
expect(matches[0].uuid).to.equal(unpinnedNote.uuid)
})
it('"text.length", ">", 500', async function () {
const longNote = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
text: Array(501).fill(0).join(''),
}),
)
await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'B',
text: 'b',
}),
)
const view = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.SmartView, {
title: 'Long',
predicate: {
keypath: 'text.length',
operator: '>',
value: 500,
},
}),
)
const matches = this.application.items.notesMatchingSmartView(view)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'asc',
views: [view],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes).to.deep.equal(matches)
expect(matches.length).to.equal(1)
expect(matches[0].uuid).to.equal(longNote.uuid)
})
it('"updated_at", ">", "1.days.ago"', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: Utils.generateUuid(),
password: Utils.generateUuid(),
})
const recentNote = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
await this.application.sync.sync()
const olderNote = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'B',
text: 'b',
}),
)
const threeDays = 3 * 24 * 60 * 60 * 1000
await Factory.changePayloadUpdatedAt(this.application, olderNote.payload, new Date(Date.now() - threeDays))
/** Create an unsynced note which shouldn't get an updated_at */
await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'B',
text: 'b',
}),
)
const view = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.SmartView, {
title: 'One day ago',
predicate: {
keypath: 'serverUpdatedAt',
operator: '>',
value: '1.days.ago',
},
}),
)
const matches = this.application.items.notesMatchingSmartView(view)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'asc',
views: [view],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes).to.deep.equal(matches)
expect(matches.length).to.equal(1)
expect(matches[0].uuid).to.equal(recentNote.uuid)
})
it('"tags.length", "=", 0', async function () {
const untaggedNote = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
const taggedNote = await Factory.createMappedNote(this.application)
const tag = await this.application.mutator.findOrCreateTag('A')
await this.application.mutator.changeItem(tag, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(taggedNote)
})
const view = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.SmartView, {
title: 'Untagged',
predicate: {
keypath: 'tags.length',
operator: '=',
value: 0,
},
}),
)
const matches = this.application.items.notesMatchingSmartView(view)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'asc',
views: [view],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes).to.deep.equal(matches)
expect(matches.length).to.equal(1)
expect(matches[0].uuid).to.equal(untaggedNote.uuid)
})
it('"tags", "includes", ["title", "startsWith", "b"]', async function () {
const taggedNote = await Factory.createMappedNote(this.application)
const tag = await this.application.mutator.findOrCreateTag('B')
await this.application.mutator.changeItem(tag, (mutator) => {
mutator.e2ePendingRefactor_addItemAsRelationship(taggedNote)
})
await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
const view = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.SmartView, {
title: 'B-tags',
predicate: {
keypath: 'tags',
operator: 'includes',
value: { keypath: 'title', operator: 'startsWith', value: 'B' },
},
}),
)
const matches = this.application.items.notesMatchingSmartView(view)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'asc',
views: [view],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes).to.deep.equal(matches)
expect(matches.length).to.equal(1)
expect(matches[0].uuid).to.equal(taggedNote.uuid)
})
it('"ignored", "and", [["pinned", "=", true], ["locked", "=", true]]', async function () {
const pinnedAndLockedNote = await Factory.createMappedNote(this.application)
await this.application.mutator.changeItem(pinnedAndLockedNote, (mutator) => {
mutator.pinned = true
mutator.locked = true
})
const pinnedNote = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
await this.application.mutator.changeItem(pinnedNote, (mutator) => {
mutator.pinned = true
})
const lockedNote = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
await this.application.mutator.changeItem(lockedNote, (mutator) => {
mutator.locked = true
})
const view = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.SmartView, {
title: 'Pinned & Locked',
predicate: {
operator: 'and',
value: [
{ keypath: 'pinned', operator: '=', value: true },
{ keypath: 'locked', operator: '=', value: true },
],
},
}),
)
const matches = this.application.items.notesMatchingSmartView(view)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'asc',
views: [view],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes).to.deep.equal(matches)
expect(matches.length).to.equal(1)
expect(matches[0].uuid).to.equal(pinnedAndLockedNote.uuid)
})
it('"ignored", "or", [["content.protected", "=", true], ["pinned", "=", true]]', async function () {
const protectedNote = await Factory.createMappedNote(this.application)
await this.application.mutator.changeItem(protectedNote, (mutator) => {
mutator.protected = true
})
const pinnedNote = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
await this.application.mutator.changeItem(pinnedNote, (mutator) => {
mutator.pinned = true
})
const pinnedAndProtectedNote = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
await this.application.mutator.changeItem(pinnedAndProtectedNote, (mutator) => {
mutator.pinned = true
mutator.protected = true
})
await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.Note, {
title: 'A',
}),
)
const view = await this.application.mutator.insertItem(
await this.application.mutator.createTemplateItem(ContentType.SmartView, {
title: 'Protected or Pinned',
predicate: {
operator: 'or',
value: [
{ keypath: 'content.protected', operator: '=', value: true },
{ keypath: 'pinned', operator: '=', value: true },
],
},
}),
)
const matches = this.application.items.notesMatchingSmartView(view)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'created_at',
sortDirection: 'asc',
views: [view],
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes.length).to.equal(matches.length)
expect(matches.length).to.equal(3)
expect(matches.find((note) => note.uuid === protectedNote.uuid)).to.exist
expect(matches.find((note) => note.uuid === pinnedNote.uuid)).to.exist
expect(matches.find((note) => note.uuid === pinnedAndProtectedNote.uuid)).to.exist
})
})
it('include notes that have tag titles that match search query', async function () {
const [notePayload1, tagPayload1] = createRelatedNoteTagPairPayload({
noteTitle: 'A simple note',
noteText: 'This is just a note.',
tagTitle: 'Test',
})
const notePayload2 = Factory.createNotePayload('Foo')
const notePayload3 = Factory.createNotePayload('Bar')
const notePayload4 = Factory.createNotePayload('Testing')
await this.application.itemManager.emitItemsFromPayloads(
[notePayload1, notePayload2, notePayload3, notePayload4, tagPayload1],
PayloadEmitSource.LocalChanged,
)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'dsc',
searchQuery: {
query: 'Test',
},
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes.length).to.equal(2)
/** setPrimaryItemDisplayOptions inverses sort for title */
expect(displayedNotes[0].uuid).to.equal(notePayload1.uuid)
expect(displayedNotes[1].uuid).to.equal(notePayload4.uuid)
})
it('search query should be case insensitive and match notes and tags title', async function () {
const [notePayload1, tagPayload1] = createRelatedNoteTagPairPayload({
noteTitle: 'A simple note',
noteText: 'Just a note. Nothing to see.',
tagTitle: 'Foo',
})
const notePayload2 = Factory.createNotePayload('Another bar (foo)')
const notePayload3 = Factory.createNotePayload('Testing FOO (Bar)')
const notePayload4 = Factory.createNotePayload('This should not match')
await this.application.itemManager.emitItemsFromPayloads(
[notePayload1, notePayload2, notePayload3, notePayload4, tagPayload1],
PayloadEmitSource.LocalChanged,
)
this.application.items.setPrimaryItemDisplayOptions({
sortBy: 'title',
sortDirection: 'dsc',
searchQuery: {
query: 'foo',
},
})
const displayedNotes = this.application.items.getDisplayableNotes()
expect(displayedNotes.length).to.equal(3)
/** setPrimaryItemDisplayOptions inverses sort for title */
expect(displayedNotes[0].uuid).to.equal(notePayload1.uuid)
expect(displayedNotes[1].uuid).to.equal(notePayload2.uuid)
expect(displayedNotes[2].uuid).to.equal(notePayload3.uuid)
})
})

View File

@@ -0,0 +1,86 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('tags as folders', () => {
beforeEach(async function () {
this.application = await Factory.createInitAppWithFakeCrypto()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
})
it('lets me create a tag, add relationships, move a note to a children, and query data all along', async function () {
// ## The user creates four tags
let tagChildren = await Factory.createMappedTag(this.application, {
title: 'children',
})
let tagParent = await Factory.createMappedTag(this.application, {
title: 'parent',
})
let tagGrandParent = await Factory.createMappedTag(this.application, {
title: 'grandparent',
})
let tagGrandParent2 = await Factory.createMappedTag(this.application, {
title: 'grandparent2',
})
// ## Now the users moves the tag children into the parent
await this.application.mutator.setTagParent(tagParent, tagChildren)
expect(this.application.items.getTagParent(tagChildren)).to.equal(tagParent)
expect(Uuids(this.application.items.getTagChildren(tagParent))).deep.to.equal(Uuids([tagChildren]))
// ## Now the user moves the tag parent into the grand parent
await this.application.mutator.setTagParent(tagGrandParent, tagParent)
expect(this.application.items.getTagParent(tagParent)).to.equal(tagGrandParent)
expect(Uuids(this.application.items.getTagChildren(tagGrandParent))).deep.to.equal(Uuids([tagParent]))
// ## Now the user moves the tag parent into another grand parent
await this.application.mutator.setTagParent(tagGrandParent2, tagParent)
expect(this.application.items.getTagParent(tagParent)).to.equal(tagGrandParent2)
expect(this.application.items.getTagChildren(tagGrandParent)).deep.to.equal([])
expect(Uuids(this.application.items.getTagChildren(tagGrandParent2))).deep.to.equal(Uuids([tagParent]))
// ## Now the user tries to move the tag into one of its children
await expect(this.application.mutator.setTagParent(tagChildren, tagParent)).to.eventually.be.rejected
expect(this.application.items.getTagParent(tagParent)).to.equal(tagGrandParent2)
expect(this.application.items.getTagChildren(tagGrandParent)).deep.to.equal([])
expect(Uuids(this.application.items.getTagChildren(tagGrandParent2))).deep.to.equal(Uuids([tagParent]))
// ## Now the user move the tag outside any hierarchy
await this.application.mutator.unsetTagParent(tagParent)
expect(this.application.items.getTagParent(tagParent)).to.equal(undefined)
expect(this.application.items.getTagChildren(tagGrandParent2)).deep.to.equals([])
})
it('lets me add a note to a tag hierarchy', async function () {
// ## The user creates four tags hierarchy
const tags = await Factory.createTags(this.application, {
grandparent: { parent: { child: true } },
another: true,
})
const note1 = await Factory.createMappedNote(this.application, 'my first note')
const note2 = await Factory.createMappedNote(this.application, 'my second note')
// ## The user add a note to the child tag
await this.application.items.addTagToNote(note1, tags.child, true)
await this.application.items.addTagToNote(note2, tags.another, true)
// ## The note has been added to other tags
const note1Tags = await this.application.items.getSortedTagsForNote(note1)
const note2Tags = await this.application.items.getSortedTagsForNote(note2)
expect(note1Tags.length).to.equal(3)
expect(note2Tags.length).to.equal(1)
})
})

View File

@@ -0,0 +1,140 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('mapping performance', () => {
it('shouldnt take a long time', async () => {
/*
There was an issue with mapping where we were using arrays for everything instead of hashes (like items, missedReferences),
which caused searching to be really expensive and caused a huge slowdown.
*/
const application = await Factory.createInitAppWithFakeCrypto()
// create a bunch of notes and tags, and make sure mapping doesn't take a long time
const noteCount = 1500
const tagCount = 10
const tags = []
const notes = []
for (let i = 0; i < tagCount; i++) {
var tag = {
uuid: UuidGenerator.GenerateUuid(),
content_type: ContentType.Tag,
content: {
title: `${Math.random()}`,
references: [],
},
}
tags.push(tag)
}
for (let i = 0; i < noteCount; i++) {
const note = {
uuid: UuidGenerator.GenerateUuid(),
content_type: ContentType.Note,
content: {
title: `${Math.random()}`,
text: `${Math.random()}`,
references: [],
},
}
const randomTag = Factory.randomArrayValue(tags)
randomTag.content.references.push({
content_type: ContentType.Note,
uuid: note.uuid,
})
notes.push(note)
}
const payloads = Factory.shuffleArray(tags.concat(notes)).map((item) => {
return new DecryptedPayload(item)
})
const t0 = performance.now()
// process items in separate batches, so as to trigger missed references
let currentIndex = 0
const batchSize = 100
for (let i = 0; i < payloads.length; i += batchSize) {
const subArray = payloads.slice(currentIndex, currentIndex + batchSize)
await application.itemManager.emitItemsFromPayloads(subArray, PayloadEmitSource.LocalChanged)
currentIndex += batchSize
}
const t1 = performance.now()
const seconds = (t1 - t0) / 1000
const expectedRunTime = 3 // seconds
expect(seconds).to.be.at.most(expectedRunTime)
for (const note of application.itemManager.getItems(ContentType.Note)) {
expect(application.itemManager.itemsReferencingItem(note).length).to.be.above(0)
}
await Factory.safeDeinit(application)
}).timeout(20000)
it('mapping a tag with thousands of notes should be quick', async () => {
/*
There was an issue where if you have a tag with thousands of notes, it will take minutes to resolve.
Fixed now. The issue was that we were looping around too much. I've consolidated some of the loops
so that things require less loops in payloadManager, regarding missedReferences.
*/
const application = await Factory.createInitAppWithFakeCrypto()
const noteCount = 10000
const notes = []
const tag = {
uuid: UuidGenerator.GenerateUuid(),
content_type: ContentType.Tag,
content: {
title: `${Math.random()}`,
references: [],
},
}
for (let i = 0; i < noteCount; i++) {
const note = {
uuid: UuidGenerator.GenerateUuid(),
content_type: ContentType.Note,
content: {
title: `${Math.random()}`,
text: `${Math.random()}`,
references: [],
},
}
tag.content.references.push({
content_type: ContentType.Note,
uuid: note.uuid,
})
notes.push(note)
}
const payloads = [tag].concat(notes).map((item) => new DecryptedPayload(item))
const t0 = performance.now()
// process items in separate batches, so as to trigger missed references
let currentIndex = 0
const batchSize = 100
for (let i = 0; i < payloads.length; i += batchSize) {
var subArray = payloads.slice(currentIndex, currentIndex + batchSize)
await application.itemManager.emitItemsFromPayloads(subArray, PayloadEmitSource.LocalChanged)
currentIndex += batchSize
}
const t1 = performance.now()
const seconds = (t1 - t0) / 1000
/** Expected run time depends on many different factors,
* like how many other tests you're running and overall system capacity.
* Locally, best case should be around 3.3s and worst case should be 5s.
* However on CI this can sometimes take up to 10s.
*/
const MAX_RUN_TIME = 15.0 // seconds
expect(seconds).to.be.at.most(MAX_RUN_TIME)
application.itemManager.getItems(ContentType.Tag)[0]
for (const note of application.itemManager.getItems(ContentType.Note)) {
expect(application.itemManager.itemsReferencingItem(note).length).to.equal(1)
}
await Factory.safeDeinit(application)
}).timeout(20000)
})

View File

@@ -0,0 +1,154 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('mutator', () => {
beforeEach(async function () {
this.createBarePayload = () => {
return new DecryptedPayload({
uuid: '123',
content_type: ContentType.Note,
content: {
title: 'hello',
},
})
}
this.createNote = () => {
return new DecryptedItem(this.createBarePayload())
}
this.createTag = (notes = []) => {
const references = notes.map((note) => {
return {
uuid: note.uuid,
content_type: note.content_type,
}
})
return new SNTag(
new DecryptedPayload({
uuid: Factory.generateUuidish(),
content_type: ContentType.Tag,
content: {
title: 'thoughts',
references: references,
},
}),
)
}
})
it('mutate set domain data key', function () {
const item = this.createNote()
const mutator = new DecryptedItemMutator(item)
mutator.setDomainDataKey('somekey', 'somevalue', 'somedomain')
const payload = mutator.getResult()
expect(payload.content.appData.somedomain.somekey).to.equal('somevalue')
})
it('mutate set pinned', function () {
const item = this.createNote()
const mutator = new DecryptedItemMutator(item)
mutator.pinned = true
const payload = mutator.getResult()
expect(payload.content.appData[DecryptedItem.DefaultAppDomain()].pinned).to.equal(true)
})
it('mutate set archived', function () {
const item = this.createNote()
const mutator = new DecryptedItemMutator(item)
mutator.archived = true
const payload = mutator.getResult()
expect(payload.content.appData[DecryptedItem.DefaultAppDomain()].archived).to.equal(true)
})
it('mutate set locked', function () {
const item = this.createNote()
const mutator = new DecryptedItemMutator(item)
mutator.locked = true
const payload = mutator.getResult()
expect(payload.content.appData[DecryptedItem.DefaultAppDomain()].locked).to.equal(true)
})
it('mutate set protected', function () {
const item = this.createNote()
const mutator = new DecryptedItemMutator(item)
mutator.protected = true
const payload = mutator.getResult()
expect(payload.content.protected).to.equal(true)
})
it('mutate set trashed', function () {
const item = this.createNote()
const mutator = new DecryptedItemMutator(item)
mutator.trashed = true
const payload = mutator.getResult()
expect(payload.content.trashed).to.equal(true)
})
it('calling get result should set us dirty', function () {
const item = this.createNote()
const mutator = new DecryptedItemMutator(item)
const payload = mutator.getResult()
expect(payload.dirty).to.equal(true)
})
it('get result should always have userModifiedDate', function () {
const item = this.createNote()
const mutator = new DecryptedItemMutator(item)
const payload = mutator.getResult()
const resultItem = CreateDecryptedItemFromPayload(payload)
expect(resultItem.userModifiedDate).to.be.ok
})
it('mutate set deleted', function () {
const item = this.createNote()
const mutator = new DeleteItemMutator(item)
const payload = mutator.getDeletedResult()
expect(payload.content).to.not.be.ok
expect(payload.deleted).to.equal(true)
expect(payload.dirty).to.equal(true)
})
it('mutate app data', function () {
const item = this.createNote()
const mutator = new DecryptedItemMutator(item, MutationType.UpdateUserTimestamps)
mutator.setAppDataItem('foo', 'bar')
mutator.setAppDataItem('bar', 'foo')
const payload = mutator.getResult()
expect(payload.content.appData[DecryptedItem.DefaultAppDomain()].foo).to.equal('bar')
expect(payload.content.appData[DecryptedItem.DefaultAppDomain()].bar).to.equal('foo')
})
it('mutate add item as relationship', function () {
const note = this.createNote()
const tag = this.createTag()
const mutator = new DecryptedItemMutator(tag)
mutator.e2ePendingRefactor_addItemAsRelationship(note)
const payload = mutator.getResult()
const item = new DecryptedItem(payload)
expect(item.isReferencingItem(note)).to.equal(true)
})
it('mutate remove item as relationship', function () {
const note = this.createNote()
const tag = this.createTag([note])
const mutator = new DecryptedItemMutator(tag)
mutator.removeItemAsRelationship(note)
const payload = mutator.getResult()
const item = new DecryptedItem(payload)
expect(item.isReferencingItem(note)).to.equal(false)
})
})

View File

@@ -0,0 +1,706 @@
/* eslint-disable no-undef */
chai.use(chaiAsPromised)
const expect = chai.expect
describe('note display criteria', function () {
beforeEach(async function () {
this.payloadManager = new PayloadManager()
this.itemManager = new ItemManager(this.payloadManager)
this.createNote = async (title = 'hello', text = 'world') => {
return this.itemManager.createItem(ContentType.Note, {
title: title,
text: text,
})
}
this.createTag = async (notes = [], title = 'thoughts') => {
const references = notes.map((note) => {
return {
uuid: note.uuid,
content_type: note.content_type,
}
})
return this.itemManager.createItem(ContentType.Tag, {
title: title,
references: references,
})
}
})
it('includePinned off', async function () {
await this.createNote()
const pendingPin = await this.createNote()
await this.itemManager.changeItem(pendingPin, (mutator) => {
mutator.pinned = true
})
const criteria = {
includePinned: false,
}
expect(
itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection)
.length,
).to.equal(1)
})
it('includePinned on', async function () {
await this.createNote()
const pendingPin = await this.createNote()
await this.itemManager.changeItem(pendingPin, (mutator) => {
mutator.pinned = true
})
const criteria = { includePinned: true }
expect(
itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection)
.length,
).to.equal(2)
})
it('includeTrashed off', async function () {
await this.createNote()
const pendingTrash = await this.createNote()
await this.itemManager.changeItem(pendingTrash, (mutator) => {
mutator.trashed = true
})
const criteria = { includeTrashed: false }
expect(
itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection)
.length,
).to.equal(1)
})
it('includeTrashed on', async function () {
await this.createNote()
const pendingTrash = await this.createNote()
await this.itemManager.changeItem(pendingTrash, (mutator) => {
mutator.trashed = true
})
const criteria = { includeTrashed: true }
expect(
itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection)
.length,
).to.equal(2)
})
it('includeArchived off', async function () {
await this.createNote()
const pendingArchive = await this.createNote()
await this.itemManager.changeItem(pendingArchive, (mutator) => {
mutator.archived = true
})
const criteria = { includeArchived: false }
expect(
itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection)
.length,
).to.equal(1)
})
it('includeArchived on', async function () {
await this.createNote()
const pendingArchive = await this.createNote()
await this.itemManager.changeItem(pendingArchive, (mutator) => {
mutator.archived = true
})
const criteria = {
includeArchived: true,
}
expect(
itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection)
.length,
).to.equal(2)
})
it('includeProtected off', async function () {
await this.createNote()
const pendingProtected = await this.createNote()
await this.itemManager.changeItem(pendingProtected, (mutator) => {
mutator.protected = true
})
const criteria = { includeProtected: false }
expect(
itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection)
.length,
).to.equal(1)
})
it('includeProtected on', async function () {
await this.createNote()
const pendingProtected = await this.createNote()
await this.itemManager.changeItem(pendingProtected, (mutator) => {
mutator.protected = true
})
const criteria = {
includeProtected: true,
}
expect(
itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection)
.length,
).to.equal(2)
})
it('protectedSearchEnabled false', async function () {
const normal = await this.createNote('hello', 'world')
await this.itemManager.changeItem(normal, (mutator) => {
mutator.protected = true
})
const criteria = {
searchQuery: { query: 'world', includeProtectedNoteText: false },
}
expect(
itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection)
.length,
).to.equal(0)
})
it('protectedSearchEnabled true', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.protected = true
})
const criteria = {
searchQuery: { query: 'world', includeProtectedNoteText: true },
}
expect(
itemsMatchingOptions(criteria, this.itemManager.collection.all(ContentType.Note), this.itemManager.collection)
.length,
).to.equal(1)
})
it('tags', async function () {
const note = await this.createNote()
const tag = await this.createTag([note])
const looseTag = await this.createTag([], 'loose')
const matchingCriteria = {
tags: [tag],
}
expect(
itemsMatchingOptions(
matchingCriteria,
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
const nonmatchingCriteria = {
tags: [looseTag],
}
expect(
itemsMatchingOptions(
nonmatchingCriteria,
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
})
describe('smart views', function () {
it('normal note', async function () {
await this.createNote()
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.archivedSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
})
it('trashed note', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.trashed = true
})
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeTrashed: false,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.archivedSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
})
it('archived note', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.trashed = false
mutator.archived = true
})
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeArchived: false,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.archivedSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
})
it('archived + trashed note', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.trashed = true
mutator.archived = true
})
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.archivedSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
})
})
describe('includeTrash', function () {
it('normal note', async function () {
await this.createNote()
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeTrashed: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
includeTrashed: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
})
it('trashed note', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.trashed = true
})
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeTrashed: false,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeTrashed: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
includeTrashed: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.archivedSmartView],
includeTrashed: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
})
it('archived + trashed note', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.trashed = true
mutator.archived = true
})
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.archivedSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
})
})
describe('includeArchived', function () {
it('normal note', async function () {
await this.createNote()
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
})
it('archived note', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.archived = true
})
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeArchived: false,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.archivedSmartView],
includeArchived: false,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
})
it('archived + trashed note', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.trashed = true
mutator.archived = true
})
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.archivedSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
})
})
describe.skip('multiple tags', function () {
it('normal note', async function () {
await this.createNote()
expect(
itemsMatchingOptions(
{
views: [
this.itemManager.allNotesSmartView,
this.itemManager.archivedSmartView,
this.itemManager.trashSmartView,
],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
})
it('archived note', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.archived = true
})
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeArchived: false,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.archivedSmartView],
includeArchived: false,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
})
it('archived + trashed note', async function () {
const normal = await this.createNote()
await this.itemManager.changeItem(normal, (mutator) => {
mutator.trashed = true
mutator.archived = true
})
expect(
itemsMatchingOptions(
{
views: [this.itemManager.allNotesSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.trashSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(1)
expect(
itemsMatchingOptions(
{
views: [this.itemManager.archivedSmartView],
includeArchived: true,
},
this.itemManager.collection.all(ContentType.Note),
this.itemManager.collection,
).length,
).to.equal(0)
})
})
})

View File

@@ -0,0 +1,137 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
chai.use(chaiAsPromised)
const expect = chai.expect
import * as Factory from './lib/factory.js'
describe('payload', () => {
beforeEach(async function () {
this.createBarePayload = () => {
return new DecryptedPayload({
uuid: '123',
content_type: ContentType.Note,
content: {
title: 'hello',
},
})
}
this.createEncryptedPayload = () => {
return new EncryptedPayload({
uuid: '123',
content_type: ContentType.Note,
content: '004:foo:bar',
})
}
})
it('constructor should set expected fields', function () {
const payload = this.createBarePayload()
expect(payload.uuid).to.be.ok
expect(payload.content_type).to.be.ok
expect(payload.content).to.be.ok
})
it('not supplying source should default to constructor source', function () {
const payload = new DecryptedPayload({
uuid: '123',
content_type: ContentType.Note,
content: {
title: 'hello',
},
})
expect(payload.source).to.equal(PayloadSource.Constructor)
})
it('created at should default to present', function () {
const payload = this.createBarePayload()
expect(payload.created_at - new Date()).to.be.below(1)
})
it('updated at should default to epoch', function () {
const payload = this.createBarePayload()
expect(payload.updated_at.getTime()).to.equal(0)
})
it('payload format bare', function () {
const payload = this.createBarePayload()
expect(isDecryptedPayload(payload)).to.equal(true)
})
it('payload format encrypted string', function () {
const payload = this.createEncryptedPayload()
expect(isEncryptedPayload(payload)).to.equal(true)
})
it('payload with unrecognized prefix should be corrupt', async function () {
await Factory.expectThrowsAsync(
() =>
new EncryptedPayload({
uuid: '123',
content_type: ContentType.Note,
content: '000:somebase64string',
}),
'Unrecognized protocol version 000',
)
})
it('payload format deleted', function () {
const payload = new DeletedPayload({
uuid: '123',
content_type: ContentType.Note,
deleted: true,
})
expect(isDeletedPayload(payload)).to.equal(true)
})
it('payload version 004', function () {
const payload = this.createEncryptedPayload()
expect(payload.version).to.equal('004')
})
it('merged with absent content', function () {
const payload = this.createBarePayload()
const merged = payload.copy({
uuid: '123',
content_type: ContentType.Note,
updated_at: new Date(),
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
})
expect(merged.content).to.eql(payload.content)
expect(merged.uuid).to.equal(payload.uuid)
expect(merged.dirty).to.equal(true)
expect(merged.updated_at.getTime()).to.be.above(1)
})
it('deleted and not dirty should be discardable', function () {
const payload = new DeletedPayload({
uuid: '123',
content_type: ContentType.Note,
deleted: true,
dirty: false,
})
expect(payload.discardable).to.equal(true)
})
it('should be immutable', async function () {
const payload = this.createBarePayload()
await Factory.sleep(0.1)
const changeFn = () => {
payload.foo = 'bar'
}
expect(changeFn).to.throw()
})
})

View File

@@ -0,0 +1,194 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
import { createRelatedNoteTagPairPayload } from './lib/Items.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('payload encryption', function () {
beforeEach(async function () {
this.timeout(Factory.TenSecondTimeout)
localStorage.clear()
this.application = await Factory.createInitAppWithFakeCrypto()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
localStorage.clear()
})
it('creating payload from item should create copy not by reference', async function () {
const item = await Factory.createMappedNote(this.application)
const payload = new DecryptedPayload(item.payload.ejected())
expect(item.content === payload.content).to.equal(false)
expect(item.content.references === payload.content.references).to.equal(false)
})
it('creating payload from item should preserve appData', async function () {
const item = await Factory.createMappedNote(this.application)
const payload = new DecryptedPayload(item.payload.ejected())
expect(item.content.appData).to.be.ok
expect(JSON.stringify(item.content)).to.equal(JSON.stringify(payload.content))
})
it('server payloads should not contain client values', async function () {
const rawPayload = Factory.createNotePayload()
const notePayload = new DecryptedPayload({
...rawPayload,
dirty: true,
dirtyIndex: getIncrementedDirtyIndex(),
lastSyncBegan: new Date(),
})
const encryptedPayload = await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [notePayload],
},
})
const syncPayload = CreateEncryptedServerSyncPushPayload(encryptedPayload)
expect(syncPayload.dirty).to.not.be.ok
expect(syncPayload.errorDecrypting).to.not.be.ok
expect(syncPayload.waitingForKey).to.not.be.ok
expect(syncPayload.lastSyncBegan).to.not.be.ok
})
it('creating payload with override properties', function () {
const payload = Factory.createNotePayload()
const uuid = payload.uuid
const changedUuid = 'foo'
const changedPayload = new DecryptedPayload({
...payload,
uuid: changedUuid,
})
expect(payload.uuid).to.equal(uuid)
expect(changedPayload.uuid).to.equal(changedUuid)
})
it('creating payload with deep override properties', function () {
const payload = Factory.createNotePayload()
const text = payload.content.text
const changedText = `${Math.random()}`
const changedPayload = new DecryptedPayload({
...payload,
content: {
...payload.content,
text: changedText,
},
})
expect(payload.content === changedPayload.content).to.equal(false)
expect(payload.content.text).to.equal(text)
expect(changedPayload.content.text).to.equal(changedText)
})
it('copying payload with override content should override completely', async function () {
const item = await Factory.createMappedNote(this.application)
const payload = new DecryptedPayload(item.payload.ejected())
const mutated = new DecryptedPayload({
...payload,
content: {
foo: 'bar',
},
})
expect(mutated.content.text).to.not.be.ok
})
it('copying payload with override should copy empty arrays', function () {
const pair = createRelatedNoteTagPairPayload()
const tagPayload = pair[1]
expect(tagPayload.content.references.length).to.equal(1)
const mutated = new DecryptedPayload({
...tagPayload,
content: {
...tagPayload.content,
references: [],
},
})
expect(mutated.content.references.length).to.equal(0)
})
it('returns valid encrypted params for syncing', async function () {
const payload = Factory.createNotePayload()
const encryptedPayload = CreateEncryptedServerSyncPushPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(encryptedPayload.enc_item_key).to.be.ok
expect(encryptedPayload.uuid).to.be.ok
expect(encryptedPayload.auth_hash).to.not.be.ok
expect(encryptedPayload.content_type).to.be.ok
expect(encryptedPayload.created_at).to.be.ok
expect(encryptedPayload.content).to.satisfy((string) => {
return string.startsWith(this.application.protocolService.getLatestVersion())
})
}).timeout(5000)
it('returns additional fields for local storage', async function () {
const payload = Factory.createNotePayload()
const encryptedPayload = CreateEncryptedLocalStorageContextPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(encryptedPayload.enc_item_key).to.be.ok
expect(encryptedPayload.auth_hash).to.not.be.ok
expect(encryptedPayload.uuid).to.be.ok
expect(encryptedPayload.content_type).to.be.ok
expect(encryptedPayload.created_at).to.be.ok
expect(encryptedPayload.updated_at).to.be.ok
expect(encryptedPayload.deleted).to.not.be.ok
expect(encryptedPayload.errorDecrypting).to.not.be.ok
expect(encryptedPayload.content).to.satisfy((string) => {
return string.startsWith(this.application.protocolService.getLatestVersion())
})
})
it('omits deleted for export file', async function () {
const payload = Factory.createNotePayload()
const encryptedPayload = CreateEncryptedBackupFileContextPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(encryptedPayload.enc_item_key).to.be.ok
expect(encryptedPayload.uuid).to.be.ok
expect(encryptedPayload.content_type).to.be.ok
expect(encryptedPayload.created_at).to.be.ok
expect(encryptedPayload.deleted).to.not.be.ok
expect(encryptedPayload.content).to.satisfy((string) => {
return string.startsWith(this.application.protocolService.getLatestVersion())
})
})
it('items with error decrypting should remain as is', async function () {
const payload = Factory.createNotePayload()
const mutatedPayload = new EncryptedPayload({
...payload,
content: '004:...',
enc_item_key: 'foo',
errorDecrypting: true,
})
const syncPayload = CreateEncryptedServerSyncPushPayload(mutatedPayload)
expect(syncPayload.content).to.eql(mutatedPayload.content)
expect(syncPayload.enc_item_key).to.be.ok
expect(syncPayload.uuid).to.be.ok
expect(syncPayload.content_type).to.be.ok
expect(syncPayload.created_at).to.be.ok
})
})

View File

@@ -0,0 +1,89 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('payload manager', () => {
beforeEach(async function () {
this.payloadManager = new PayloadManager()
this.createNotePayload = async () => {
return new DecryptedPayload({
uuid: Factory.generateUuidish(),
content_type: ContentType.Note,
content: {
title: 'hello',
text: 'world',
},
})
}
})
it('emit payload should create local record', async function () {
const payload = await this.createNotePayload()
await this.payloadManager.emitPayload(payload)
expect(this.payloadManager.collection.find(payload.uuid)).to.be.ok
})
it('merge payloads onto master', async function () {
const payload = await this.createNotePayload()
await this.payloadManager.emitPayload(payload)
const newTitle = `${Math.random()}`
const changedPayload = payload.copy({
content: {
...payload.content,
title: newTitle,
},
})
const { changed, inserted } = await this.payloadManager.applyPayloads([changedPayload])
expect(changed.length).to.equal(1)
expect(inserted.length).to.equal(0)
expect(this.payloadManager.collection.find(payload.uuid).content.title).to.equal(newTitle)
})
it('insertion observer', async function () {
const observations = []
this.payloadManager.addObserver(ContentType.Any, ({ inserted }) => {
observations.push({ inserted })
})
const payload = await this.createNotePayload()
await this.payloadManager.emitPayload(payload)
expect(observations.length).equal(1)
expect(observations[0].inserted[0]).equal(payload)
})
it('change observer', async function () {
const observations = []
this.payloadManager.addObserver(ContentType.Any, ({ changed }) => {
if (changed.length > 0) {
observations.push({ changed })
}
})
const payload = await this.createNotePayload()
await this.payloadManager.emitPayload(payload)
await this.payloadManager.emitPayload(
payload.copy({
content: {
...payload.content,
title: 'new title',
},
}),
)
expect(observations.length).equal(1)
expect(observations[0].changed[0].uuid).equal(payload.uuid)
})
it('reset state', async function () {
this.payloadManager.addObserver(ContentType.Any, ({}) => {})
const payload = await this.createNotePayload()
await this.payloadManager.emitPayload(payload)
await this.payloadManager.resetState()
expect(this.payloadManager.collection.all().length).to.equal(0)
expect(this.payloadManager.changeObservers.length).equal(1)
})
})

View File

@@ -0,0 +1,102 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('preferences', function () {
beforeEach(async function () {
localStorage.clear()
this.application = await Factory.createInitAppWithFakeCrypto()
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
localStorage.clear()
})
function register() {
return Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
}
it('sets preference', async function () {
await this.application.setPreference('editorLeft', 300)
expect(this.application.getPreference('editorLeft')).to.equal(300)
})
it('saves preference', async function () {
await register.call(this)
await this.application.setPreference('editorLeft', 300)
await this.application.sync.sync()
this.application = await Factory.signOutAndBackIn(this.application, this.email, this.password)
const editorLeft = this.application.getPreference('editorLeft')
expect(editorLeft).to.equal(300)
}).timeout(10000)
it('clears preferences on signout', async function () {
await register.call(this)
await this.application.setPreference('editorLeft', 300)
await this.application.sync.sync()
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
expect(this.application.getPreference('editorLeft')).to.equal(undefined)
})
it('returns default value for non-existent preference', async function () {
await register.call(this)
const editorLeft = this.application.getPreference('editorLeft', 100)
expect(editorLeft).to.equal(100)
})
it('emits an event when preferences change', async function () {
let callTimes = 0
this.application.addEventObserver(() => {
callTimes++
}, ApplicationEvent.PreferencesChanged)
callTimes += 1
await Factory.sleep(0) /** Await next tick */
expect(callTimes).to.equal(1) /** App start */
await register.call(this)
await this.application.setPreference('editorLeft', 300)
expect(callTimes).to.equal(2)
})
it('discards existing preferences when signing in', async function () {
await register.call(this)
await this.application.setPreference('editorLeft', 300)
await this.application.sync.sync()
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
await this.application.setPreference('editorLeft', 200)
await this.application.signIn(this.email, this.password)
await this.application.sync.sync({ awaitAll: true })
const editorLeft = this.application.getPreference('editorLeft')
expect(editorLeft).to.equal(300)
})
it('reads stored preferences on start without waiting for syncing to complete', async function () {
const prefKey = 'editorLeft'
const prefValue = 300
const identifier = this.application.identifier
await register.call(this)
await this.application.setPreference(prefKey, prefValue)
await this.application.sync.sync()
await Factory.safeDeinit(this.application)
this.application = Factory.createApplicationWithFakeCrypto(identifier)
const willSyncPromise = new Promise((resolve) => {
this.application.addEventObserver(resolve, ApplicationEvent.WillSync)
})
Factory.initializeApplication(this.application)
await willSyncPromise
expect(this.application.preferencesService.preferences).to.exist
expect(this.application.getPreference(prefKey)).to.equal(prefValue)
})
})

View File

@@ -0,0 +1,624 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('protections', function () {
this.timeout(Factory.TenSecondTimeout)
let application
beforeEach(function () {
localStorage.clear()
})
afterEach(async function () {
await Factory.safeDeinit(application)
localStorage.clear()
})
it('prompts for password when accessing protected note', async function () {
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
const password = UuidGenerator.GenerateUuid()
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts += 1
expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.AccountPassword)).to.be.ok
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.AccountPassword
? password
: UnprotectedAccessSecondsDuration.OneMinute,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await Factory.registerUserToApplication({
application: application,
email: UuidGenerator.GenerateUuid(),
password,
})
let note = await Factory.createMappedNote(application)
note = await application.mutator.protectNote(note)
expect(await application.authorizeNoteAccess(note)).to.be.true
expect(challengePrompts).to.equal(1)
})
it('sets `note.protected` to true', async function () {
application = await Factory.createInitAppWithFakeCrypto()
let note = await Factory.createMappedNote(application)
note = await application.mutator.protectNote(note)
expect(note.protected).to.be.true
})
it('prompts for passcode when accessing protected note', async function () {
const passcode = 'passcode'
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts += 1
expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.LocalPasscode
? passcode
: UnprotectedAccessSecondsDuration.OneMinute,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await application.addPasscode(passcode)
let note = await Factory.createMappedNote(application)
note = await application.mutator.protectNote(note)
expect(await application.authorizeNoteAccess(note)).to.be.true
expect(challengePrompts).to.equal(1)
})
it('prompts for passcode when unprotecting a note', async function () {
const passcode = 'passcode'
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts += 1
expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.LocalPasscode
? passcode
: UnprotectedAccessSecondsDuration.OneMinute,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await application.addPasscode(passcode)
let note = await Factory.createMappedNote(application)
const uuid = note.uuid
note = await application.mutator.protectNote(note)
note = await application.mutator.unprotectNote(note)
expect(note.uuid).to.equal(uuid)
expect(note.protected).to.equal(false)
expect(challengePrompts).to.equal(1)
})
it('does not unprotect note if challenge is canceled', async function () {
const passcode = 'passcode'
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts++
application.cancelChallenge(challenge)
},
})
await application.launch(true)
await application.addPasscode(passcode)
let note = await Factory.createMappedNote(application)
note = await application.mutator.protectNote(note)
const result = await application.mutator.unprotectNote(note)
expect(result).to.be.undefined
expect(challengePrompts).to.equal(1)
})
it('does not prompt for passcode again after setting a remember duration', async function () {
const passcode = 'passcode'
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts += 1
expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.LocalPasscode
? passcode
: UnprotectedAccessSecondsDuration.OneHour,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await application.addPasscode(passcode)
let note = await Factory.createMappedNote(application)
note = await application.mutator.protectNote(note)
expect(await application.authorizeNoteAccess(note)).to.be.true
expect(await application.authorizeNoteAccess(note)).to.be.true
expect(challengePrompts).to.equal(1)
})
it('prompts for password when adding a passcode', async function () {
application = Factory.createApplicationWithFakeCrypto(Factory.randomString())
const password = UuidGenerator.GenerateUuid()
const passcode = 'passcode'
let didPromptForPassword = false
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
let values
if (challenge.prompts[0].validation === ChallengeValidation.AccountPassword) {
if (challenge.reason === ChallengeReason.AddPasscode) {
didPromptForPassword = true
}
values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.AccountPassword
? password
: UnprotectedAccessSecondsDuration.OneHour,
),
)
} else {
values = [CreateChallengeValue(challenge.prompts[0], passcode)]
}
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await Factory.registerUserToApplication({
application: application,
email: UuidGenerator.GenerateUuid(),
password,
})
await application.addPasscode(passcode)
expect(didPromptForPassword).to.equal(true)
})
it('authorizes note access when no password or passcode are set', async function () {
application = await Factory.createInitAppWithFakeCrypto()
let note = await Factory.createMappedNote(application)
note = await application.mutator.protectNote(note)
expect(await application.authorizeNoteAccess(note)).to.be.true
})
it('authorizes autolock interval change', async function () {
const passcode = 'passcode'
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.LocalPasscode
? passcode
: UnprotectedAccessSecondsDuration.OneMinute,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await application.addPasscode(passcode)
expect(await application.authorizeAutolockIntervalChange()).to.be.true
})
it('authorizes batch manager access', async function () {
const passcode = 'passcode'
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.LocalPasscode
? passcode
: UnprotectedAccessSecondsDuration.OneMinute,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await application.addPasscode(passcode)
expect(await application.authorizeAutolockIntervalChange()).to.be.true
})
it('handles session length', async function () {
application = await Factory.createInitAppWithFakeCrypto()
await application.protectionService.setSessionLength(300)
const length = await application.protectionService.getLastSessionLength()
expect(length).to.equal(300)
const expirey = await application.getProtectionSessionExpiryDate()
expect(expirey).to.be.ok
})
it('handles session length', async function () {
application = await Factory.createInitAppWithFakeCrypto()
await application.protectionService.setSessionLength(UnprotectedAccessSecondsDuration.OneMinute)
const length = await application.protectionService.getLastSessionLength()
expect(length).to.equal(UnprotectedAccessSecondsDuration.OneMinute)
const expirey = await application.getProtectionSessionExpiryDate()
expect(expirey).to.be.ok
})
describe('hasProtectionSources', function () {
it('no account, no passcode, no biometrics', async function () {
application = await Factory.createInitAppWithFakeCrypto()
expect(application.hasProtectionSources()).to.be.false
})
it('no account, no passcode, biometrics', async function () {
application = await Factory.createInitAppWithFakeCrypto()
await application.enableBiometrics()
expect(application.hasProtectionSources()).to.be.true
})
it('no account, passcode, no biometrics', async function () {
application = await Factory.createInitAppWithFakeCrypto()
await application.addPasscode('passcode')
expect(application.hasProtectionSources()).to.be.true
})
it('no account, passcode, biometrics', async function () {
application = await Factory.createInitAppWithFakeCrypto()
await application.addPasscode('passcode')
await application.enableBiometrics()
expect(application.hasProtectionSources()).to.be.true
})
it('account, no passcode, no biometrics', async function () {
application = await Factory.createInitAppWithFakeCrypto()
await Factory.registerUserToApplication({
application: application,
email: UuidGenerator.GenerateUuid(),
password: UuidGenerator.GenerateUuid(),
})
expect(application.hasProtectionSources()).to.be.true
})
it('account, no passcode, biometrics', async function () {
application = await Factory.createInitAppWithFakeCrypto()
await Factory.registerUserToApplication({
application: application,
email: UuidGenerator.GenerateUuid(),
password: UuidGenerator.GenerateUuid(),
})
await application.enableBiometrics()
expect(application.hasProtectionSources()).to.be.true
})
it('account, passcode, no biometrics', async function () {
application = await Factory.createInitAppWithFakeCrypto()
const password = UuidGenerator.GenerateUuid()
await Factory.registerUserToApplication({
application: application,
email: UuidGenerator.GenerateUuid(),
password,
})
Factory.handlePasswordChallenges(application, password)
await application.addPasscode('passcode')
expect(application.hasProtectionSources()).to.be.true
})
it('account, passcode, biometrics', async function () {
application = await Factory.createInitAppWithFakeCrypto()
const password = UuidGenerator.GenerateUuid()
await Factory.registerUserToApplication({
application: application,
email: UuidGenerator.GenerateUuid(),
password,
})
Factory.handlePasswordChallenges(application, password)
await application.addPasscode('passcode')
await application.enableBiometrics()
expect(application.hasProtectionSources()).to.be.true
})
})
describe('hasUnprotectedAccessSession', function () {
it('should return false when session length has not been set', async function () {
this.foo = 'tar'
application = await Factory.createInitAppWithFakeCrypto()
await application.addPasscode('passcode')
expect(application.hasUnprotectedAccessSession()).to.be.false
})
it('should return true when session length has been set', async function () {
application = await Factory.createInitAppWithFakeCrypto()
await application.addPasscode('passcode')
await application.protectionService.setSessionLength(UnprotectedAccessSecondsDuration.OneMinute)
expect(application.hasUnprotectedAccessSession()).to.be.true
})
it('should return true when there are no protection sources', async function () {
application = await Factory.createInitAppWithFakeCrypto()
expect(application.hasUnprotectedAccessSession()).to.be.true
})
})
describe('authorizeProtectedActionForNotes', function () {
it('prompts for password once with the right challenge reason when one or more notes are protected', async function () {
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
const password = UuidGenerator.GenerateUuid()
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts += 1
expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.AccountPassword)).to.be.ok
expect(challenge.reason).to.equal(ChallengeReason.SelectProtectedNote)
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.AccountPassword
? password
: UnprotectedAccessSecondsDuration.OneMinute,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await Factory.registerUserToApplication({
application: application,
email: UuidGenerator.GenerateUuid(),
password,
})
const NOTE_COUNT = 3
let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT)
notes[0] = await application.mutator.protectNote(notes[0])
notes[1] = await application.mutator.protectNote(notes[1])
expect(await application.authorizeProtectedActionForNotes(notes, ChallengeReason.SelectProtectedNote)).lengthOf(
NOTE_COUNT,
)
expect(challengePrompts).to.equal(1)
})
it('prompts for passcode once with the right challenge reason when one or more notes are protected', async function () {
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
const passcode = 'passcode'
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts += 1
expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok
expect(challenge.reason).to.equal(ChallengeReason.SelectProtectedNote)
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.LocalPasscode
? passcode
: UnprotectedAccessSecondsDuration.OneMinute,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await application.addPasscode(passcode)
const NOTE_COUNT = 3
let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT)
notes[0] = await application.mutator.protectNote(notes[0])
notes[1] = await application.mutator.protectNote(notes[1])
expect(await application.authorizeProtectedActionForNotes(notes, ChallengeReason.SelectProtectedNote)).lengthOf(
NOTE_COUNT,
)
expect(challengePrompts).to.equal(1)
})
it('does not return protected notes if challenge is canceled', async function () {
const passcode = 'passcode'
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts++
application.cancelChallenge(challenge)
},
})
await application.launch(true)
await application.addPasscode(passcode)
const NOTE_COUNT = 3
let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT)
notes[0] = await application.mutator.protectNote(notes[0])
notes[1] = await application.mutator.protectNote(notes[1])
expect(await application.authorizeProtectedActionForNotes(notes, ChallengeReason.SelectProtectedNote)).lengthOf(1)
expect(challengePrompts).to.equal(1)
})
})
describe('protectNotes', function () {
it('protects all notes', async function () {
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
application.cancelChallenge(challenge)
},
})
await application.launch(true)
const NOTE_COUNT = 3
let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT)
notes = await application.mutator.protectNotes(notes)
for (const note of notes) {
expect(note.protected).to.be.true
}
})
})
describe('unprotect notes', function () {
it('prompts for password and unprotects all notes if challenge is succesful', async function () {
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
const passcode = 'passcode'
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts += 1
expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok
expect(challenge.reason).to.equal(ChallengeReason.UnprotectNote)
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.LocalPasscode
? passcode
: UnprotectedAccessSecondsDuration.OneMinute,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await application.addPasscode(passcode)
const NOTE_COUNT = 3
let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT)
notes = await application.mutator.protectNotes(notes)
notes = await application.mutator.unprotectNotes(notes)
for (const note of notes) {
expect(note.protected).to.be.false
}
expect(challengePrompts).to.equal(1)
})
it('prompts for passcode and unprotects all notes if challenge is succesful', async function () {
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
const passcode = 'passcode'
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts += 1
expect(challenge.prompts.find((prompt) => prompt.validation === ChallengeValidation.LocalPasscode)).to.be.ok
expect(challenge.reason).to.equal(ChallengeReason.UnprotectNote)
const values = challenge.prompts.map(
(prompt) =>
CreateChallengeValue(
prompt,
prompt.validation === ChallengeValidation.LocalPasscode
? passcode
: UnprotectedAccessSecondsDuration.OneMinute,
),
)
application.submitValuesForChallenge(challenge, values)
},
})
await application.launch(true)
await application.addPasscode(passcode)
const NOTE_COUNT = 3
let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT)
notes = await application.mutator.protectNotes(notes)
notes = await application.mutator.unprotectNotes(notes)
for (const note of notes) {
expect(note.protected).to.be.false
}
expect(challengePrompts).to.equal(1)
})
it('does not unprotect any notes if challenge is canceled', async function () {
const passcode = 'passcode'
let challengePrompts = 0
application = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await application.prepareForLaunch({
receiveChallenge: (challenge) => {
challengePrompts++
application.cancelChallenge(challenge)
},
})
await application.launch(true)
await application.addPasscode(passcode)
const NOTE_COUNT = 3
let notes = await Factory.createManyMappedNotes(application, NOTE_COUNT)
notes = await application.mutator.protectNotes(notes)
notes = await application.mutator.unprotectNotes(notes)
for (const note of notes) {
expect(note.protected).to.be(true)
}
expect(challengePrompts).to.equal(1)
})
})
})

View File

@@ -0,0 +1,198 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('protocol', function () {
beforeEach(async function () {
localStorage.clear()
this.application = await Factory.createInitAppWithFakeCrypto()
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
this.application = null
localStorage.clear()
})
it('checks version to make sure its 004', function () {
expect(this.application.protocolService.getLatestVersion()).to.equal('004')
})
it('checks supported versions to make sure it includes 001, 002, 003, 004', function () {
expect(this.application.protocolService.supportedVersions()).to.eql(['001', '002', '003', '004'])
})
it('platform derivation support', function () {
expect(
this.application.protocolService.platformSupportsKeyDerivation({
version: '001',
}),
).to.equal(true)
expect(
this.application.protocolService.platformSupportsKeyDerivation({
version: '002',
}),
).to.equal(true)
expect(
this.application.protocolService.platformSupportsKeyDerivation({
version: '003',
}),
).to.equal(true)
expect(
this.application.protocolService.platformSupportsKeyDerivation({
version: '004',
}),
).to.equal(true)
expect(
this.application.protocolService.platformSupportsKeyDerivation({
version: '005',
}),
).to.equal(true)
})
it('key params versions <= 002 should include pw_cost in portable value', function () {
const keyParams002 = this.application.protocolService.createKeyParams({
version: '002',
pw_cost: 5000,
})
expect(keyParams002.getPortableValue().pw_cost).to.be.ok
})
it('version comparison of 002 should be older than library version', function () {
expect(this.application.protocolService.isVersionNewerThanLibraryVersion('002')).to.equal(false)
})
it('version comparison of 005 should be newer than library version', function () {
expect(this.application.protocolService.isVersionNewerThanLibraryVersion('005')).to.equal(true)
})
it('library version should not be outdated', function () {
var currentVersion = this.application.protocolService.getLatestVersion()
expect(isProtocolVersionExpired(currentVersion)).to.equal(false)
})
it('001 protocol should be expired', function () {
expect(isProtocolVersionExpired(ProtocolVersion.V001)).to.equal(true)
})
it('002 protocol should be expired', function () {
expect(isProtocolVersionExpired(ProtocolVersion.V002)).to.equal(true)
})
it('004 protocol should not be expired', function () {
expect(isProtocolVersionExpired(ProtocolVersion.V004)).to.equal(false)
})
it('decrypting already decrypted payload should throw', async function () {
const payload = Factory.createNotePayload()
let error
try {
await this.application.protocolService.decryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
})
} catch (e) {
error = e
}
expect(error).to.be.ok
})
it('ejected payload should not have meta fields', async function () {
await this.application.addPasscode('123')
const payload = Factory.createNotePayload()
const result = CreateEncryptedServerSyncPushPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(result.fields).to.not.be.ok
expect(result.source).to.not.be.ok
expect(result.format).to.not.be.ok
expect(result.dirtyIndex).to.not.be.ok
})
it('encrypted payload for server should include duplicate_of field', async function () {
const payload = Factory.createNotePayload('Test')
const encryptedPayload = CreateEncryptedServerSyncPushPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(encryptedPayload).to.be.ok
expect(encryptedPayload).to.contain.keys('duplicate_of')
})
it('ejected payload for server should include duplicate_of field', async function () {
const payload = Factory.createNotePayload('Test')
const encryptedPayload = CreateEncryptedServerSyncPushPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(encryptedPayload).to.be.ok
expect(encryptedPayload).to.contain.keys('duplicate_of')
})
it('encrypted payload for storage should include duplicate_of field', async function () {
const payload = Factory.createNotePayload('Test')
const encryptedPayload = CreateEncryptedLocalStorageContextPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(encryptedPayload).to.be.ok
expect(encryptedPayload).to.contain.keys('duplicate_of')
})
it('ejected payload for storage should include duplicate_of field', async function () {
const payload = Factory.createNotePayload('Test')
const encryptedPayload = CreateEncryptedLocalStorageContextPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(encryptedPayload).to.be.ok
expect(encryptedPayload).to.contain.keys('duplicate_of')
})
it('encrypted payload for file should include duplicate_of field', async function () {
const payload = Factory.createNotePayload('Test')
const encryptedPayload = CreateEncryptedBackupFileContextPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(encryptedPayload).to.be.ok
expect(encryptedPayload).to.contain.keys('duplicate_of')
})
it('ejected payload for file should include duplicate_of field', async function () {
const payload = Factory.createNotePayload('Test')
const encryptedPayload = CreateEncryptedBackupFileContextPayload(
await this.application.protocolService.encryptSplitSingle({
usesItemsKeyWithKeyLookup: {
items: [payload],
},
}),
)
expect(encryptedPayload).to.be.ok
expect(encryptedPayload).to.contain.keys('duplicate_of')
})
})

View File

@@ -0,0 +1,113 @@
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('session sharing', function () {
this.timeout(Factory.TenSecondTimeout)
beforeEach(async function () {
localStorage.clear()
this.context = await Factory.createAppContext()
await this.context.launch()
this.application = this.context.application
this.email = this.context.email
this.password = this.context.password
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
})
afterEach(async function () {
await this.context.deinit()
this.context = undefined
this.application = undefined
localStorage.clear()
})
it('share token payloads should include neccessary params', async function () {
const token = await this.application.sessions.createDemoShareToken()
const payload = await this.application.sessions.decodeDemoShareToken(token)
const expectedKeys = [
'accessToken',
'refreshToken',
'accessExpiration',
'refreshExpiration',
'readonlyAccess',
'masterKey',
'keyParams',
'user',
'host',
]
for (const key of expectedKeys) {
expect(payload[key]).to.not.be.undefined
}
})
it('populating session from share token should allow pulling in new items', async function () {
const token = await this.application.sessions.createDemoShareToken()
await Factory.createSyncedNote(this.application, 'demo title', 'demo text')
const otherContext = await Factory.createAppContext()
await otherContext.launch()
const otherApplication = otherContext.application
expect(otherApplication.items.getItems(ContentType.Note).length).to.equal(0)
await otherApplication.sessions.populateSessionFromDemoShareToken(token)
await otherApplication.sync.sync()
const notes = otherApplication.items.getItems(ContentType.Note)
expect(notes.length).to.equal(1)
const note = notes[0]
expect(note.title).to.equal('demo title')
expect(note.text).to.equal('demo text')
await otherContext.deinit()
})
/**
* Demo session tokens can only be created manually via raw SQL entry on the DB side.
* There is no API to create share tokens. Therefore, the share token below is made from
* a copy of the master session, which is not readonly.
*/
it.skip('populating session from share token should not allow making changes', async function () {
const token = await this.application.sessions.createDemoShareToken()
await Factory.createSyncedNote(this.application, 'demo title', 'demo text')
const otherContext = await Factory.createAppContext()
await otherContext.launch()
const otherApplication = otherContext.application
await otherApplication.sessions.populateSessionFromDemoShareToken(token)
await otherApplication.sync.sync()
const note = otherApplication.items.getItems(ContentType.Note)[0]
const syncResponse = otherContext.awaitNextSyncEvent(SyncEvent.SingleRoundTripSyncCompleted)
await otherApplication.mutator.changeAndSaveItem(note, (mutator) => {
mutator.title = 'unauthorized change'
})
const result = await syncResponse
expect(result.rawResponse.unsaved_items.length).to.equal(1)
})
})

View File

@@ -0,0 +1,660 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
import WebDeviceInterface from './lib/web_device_interface.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('server session', function () {
this.timeout(Factory.TenSecondTimeout)
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
beforeEach(async function () {
localStorage.clear()
this.expectedItemCount = BASE_ITEM_COUNT
this.application = await Factory.createInitAppWithFakeCrypto()
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
this.newPassword = Factory.randomString()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
this.application = null
localStorage.clear()
})
async function sleepUntilSessionExpires(application, basedOnAccessToken = true) {
const currentSession = application.apiService.session
const timestamp = basedOnAccessToken ? currentSession.accessExpiration : currentSession.refreshExpiration
const timeRemaining = (timestamp - Date.now()) / 1000 // in ms
/*
If the token has not expired yet, we will return the remaining time.
Else, there's no need to add a delay.
*/
const sleepTime = timeRemaining > 0 ? timeRemaining + 1 /** Safety margin */ : 0
await Factory.sleep(sleepTime)
}
async function getSessionFromStorage(application) {
return application.diskStorageService.getValue(StorageKey.Session)
}
it('should succeed when a sync request is perfomed with an expired access token', async function () {
this.timeout(Factory.TwentySecondTimeout)
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
await sleepUntilSessionExpires(this.application)
const response = await this.application.apiService.sync([])
expect(response.status).to.equal(200)
})
it('should return the new session in the response when refreshed', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const response = await this.application.apiService.refreshSession()
expect(response.status).to.equal(200)
expect(response.data.session.access_token).to.be.a('string')
expect(response.data.session.access_token).to.not.be.empty
expect(response.data.session.refresh_expiration).to.be.a('number')
expect(response.data.session.refresh_token).to.not.be.empty
})
it('should be refreshed on any api call if access token is expired', async function () {
this.timeout(Factory.TwentySecondTimeout)
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
// Saving the current session information for later...
const sessionBeforeSync = this.application.apiService.getSession()
// Waiting enough time for the access token to expire, before performing a new sync request.
await sleepUntilSessionExpires(this.application)
// Performing a sync request with an expired access token.
await this.application.sync.sync(syncOptions)
// After the above sync request is completed, we obtain the session information.
const sessionAfterSync = this.application.apiService.getSession()
expect(sessionBeforeSync).to.not.equal(sessionAfterSync)
expect(sessionBeforeSync.accessToken).to.not.equal(sessionAfterSync.accessToken)
expect(sessionBeforeSync.refreshToken).to.not.equal(sessionAfterSync.refreshToken)
expect(sessionBeforeSync.accessExpiration).to.be.lessThan(sessionAfterSync.accessExpiration)
// New token should expire in the future.
expect(sessionAfterSync.accessExpiration).to.be.greaterThan(Date.now())
})
it('should succeed when a sync request is perfomed after signing into an ephemeral session', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
await this.application.signIn(this.email, this.password, false, true)
const response = await this.application.apiService.sync([])
expect(response.status).to.equal(200)
})
it('should succeed when a sync request is perfomed after registering into an ephemeral session', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: true,
})
const response = await this.application.apiService.sync([])
expect(response.status).to.equal(200)
})
it('should be consistent between storage and apiService', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const sessionFromStorage = await getSessionFromStorage(this.application)
const sessionFromApiService = this.application.apiService.getSession()
expect(sessionFromStorage).to.equal(sessionFromApiService)
await this.application.apiService.refreshSession()
const updatedSessionFromStorage = await getSessionFromStorage(this.application)
const updatedSessionFromApiService = this.application.apiService.getSession()
expect(updatedSessionFromStorage).to.equal(updatedSessionFromApiService)
})
it('should be performed successfully and terminate session with a valid access token', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const signOutResponse = await this.application.apiService.signOut()
expect(signOutResponse.status).to.equal(204)
Factory.ignoreChallenges(this.application)
const syncResponse = await this.application.apiService.sync([])
expect(syncResponse.status).to.equal(401)
expect(syncResponse.error.tag).to.equal('invalid-auth')
expect(syncResponse.error.message).to.equal('Invalid login credentials.')
})
it('sign out request should be performed successfully and terminate session with expired access token', async function () {
this.timeout(Factory.TwentySecondTimeout)
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
// Waiting enough time for the access token to expire, before performing a sign out request.
await sleepUntilSessionExpires(this.application)
const signOutResponse = await this.application.apiService.signOut()
expect(signOutResponse.status).to.equal(204)
Factory.ignoreChallenges(this.application)
const syncResponse = await this.application.apiService.sync([])
expect(syncResponse.status).to.equal(401)
expect(syncResponse.error.tag).to.equal('invalid-auth')
expect(syncResponse.error.message).to.equal('Invalid login credentials.')
})
it('change email request should be successful with a valid access token', async function () {
this.timeout(Factory.TwentySecondTimeout)
let { application, password } = await Factory.createAndInitSimpleAppContext({
registerUser: true,
})
const newEmail = UuidGenerator.GenerateUuid()
const changeEmailResponse = await application.changeEmail(newEmail, password)
expect(changeEmailResponse.status).to.equal(200)
expect(changeEmailResponse.data.user).to.be.ok
application = await Factory.signOutApplicationAndReturnNew(application)
const loginResponse = await Factory.loginToApplication({
application: application,
email: newEmail,
password: password,
})
expect(loginResponse).to.be.ok
expect(loginResponse.status).to.equal(200)
await Factory.safeDeinit(application)
})
it('change email request should fail with an invalid access token', async function () {
this.timeout(Factory.TwentySecondTimeout)
let { application, password } = await Factory.createAndInitSimpleAppContext({
registerUser: true,
})
const fakeSession = application.apiService.getSession()
fakeSession.accessToken = 'this-is-a-fake-token-1234'
Factory.ignoreChallenges(application)
const newEmail = UuidGenerator.GenerateUuid()
const changeEmailResponse = await application.changeEmail(newEmail, password)
expect(changeEmailResponse.error.message).to.equal('Invalid login credentials.')
await Factory.safeDeinit(application)
})
it('change email request should fail with an expired refresh token', async function () {
this.timeout(Factory.ThirtySecondTimeout)
let { application, email, password } = await Factory.createAndInitSimpleAppContext({
registerUser: true,
})
/** Waiting for the refresh token to expire. */
await sleepUntilSessionExpires(application, false)
Factory.ignoreChallenges(application)
const newEmail = UuidGenerator.GenerateUuid()
const changeEmailResponse = await application.changeEmail(newEmail, password)
expect(changeEmailResponse).to.be.ok
expect(changeEmailResponse.error.message).to.equal('Invalid login credentials.')
await Factory.safeDeinit(application)
})
it('change password request should be successful with a valid access token', async function () {
this.timeout(Factory.TwentySecondTimeout)
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword)
expect(changePasswordResponse.status).to.equal(200)
expect(changePasswordResponse.data.user).to.be.ok
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
const loginResponse = await Factory.loginToApplication({
application: this.application,
email: this.email,
password: this.newPassword,
})
expect(loginResponse).to.be.ok
expect(loginResponse.status).to.be.equal(200)
})
it('change password request should be successful after the expired access token is refreshed', async function () {
this.timeout(Factory.TwentySecondTimeout)
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
// Waiting enough time for the access token to expire.
await sleepUntilSessionExpires(this.application)
const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword)
expect(changePasswordResponse).to.be.ok
expect(changePasswordResponse.status).to.equal(200)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
const loginResponse = await Factory.loginToApplication({
application: this.application,
email: this.email,
password: this.newPassword,
})
expect(loginResponse).to.be.ok
expect(loginResponse.status).to.be.equal(200)
})
it('change password request should fail with an invalid access token', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const fakeSession = this.application.apiService.getSession()
fakeSession.accessToken = 'this-is-a-fake-token-1234'
Factory.ignoreChallenges(this.application)
const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword)
expect(changePasswordResponse.error.message).to.equal('Invalid login credentials.')
})
it('change password request should fail with an expired refresh token', async function () {
this.timeout(Factory.TwentySecondTimeout)
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
/** Waiting for the refresh token to expire. */
await sleepUntilSessionExpires(this.application, false)
Factory.ignoreChallenges(this.application)
const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword)
expect(changePasswordResponse).to.be.ok
expect(changePasswordResponse.error.message).to.equal('Invalid login credentials.')
}).timeout(25000)
it('should sign in successfully after signing out', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
await this.application.apiService.signOut()
this.application.apiService.session = undefined
await this.application.sessionManager.signIn(this.email, this.password)
const currentSession = this.application.apiService.getSession()
expect(currentSession).to.be.ok
expect(currentSession.accessToken).to.be.ok
expect(currentSession.refreshToken).to.be.ok
expect(currentSession.accessExpiration).to.be.greaterThan(Date.now())
})
it('should fail when renewing a session with an expired refresh token', async function () {
this.timeout(Factory.TwentySecondTimeout)
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
await sleepUntilSessionExpires(this.application, false)
const refreshSessionResponse = await this.application.apiService.refreshSession()
expect(refreshSessionResponse.status).to.equal(400)
expect(refreshSessionResponse.error.tag).to.equal('expired-refresh-token')
expect(refreshSessionResponse.error.message).to.equal('The refresh token has expired.')
/*
The access token and refresh token should be expired up to this point.
Here we make sure that any subsequent requests will fail.
*/
Factory.ignoreChallenges(this.application)
const syncResponse = await this.application.apiService.sync([])
expect(syncResponse.status).to.equal(401)
expect(syncResponse.error.tag).to.equal('invalid-auth')
expect(syncResponse.error.message).to.equal('Invalid login credentials.')
})
it('should fail when renewing a session with an invalid refresh token', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const fakeSession = this.application.apiService.getSession()
fakeSession.refreshToken = 'this-is-a-fake-token-1234'
await this.application.apiService.setSession(fakeSession, true)
const refreshSessionResponse = await this.application.apiService.refreshSession()
expect(refreshSessionResponse.status).to.equal(400)
expect(refreshSessionResponse.error.tag).to.equal('invalid-refresh-token')
expect(refreshSessionResponse.error.message).to.equal('The refresh token is not valid.')
// Access token should remain valid.
const syncResponse = await this.application.apiService.sync([])
expect(syncResponse.status).to.equal(200)
})
it('should fail if syncing while a session refresh is in progress', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const refreshPromise = this.application.apiService.refreshSession()
const syncResponse = await this.application.apiService.sync([])
expect(syncResponse.error).to.be.ok
const errorMessage = 'Your account session is being renewed with the server. Please try your request again.'
expect(syncResponse.error.message).to.be.equal(errorMessage)
/** Wait for finish so that test cleans up properly */
await refreshPromise
})
it('notes should be synced as expected after refreshing a session', async function () {
this.timeout(Factory.TwentySecondTimeout)
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const notesBeforeSync = await Factory.createManyMappedNotes(this.application, 5)
await sleepUntilSessionExpires(this.application)
await this.application.syncService.sync(syncOptions)
expect(this.application.syncService.isOutOfSync()).to.equal(false)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true)
const expectedNotesUuids = notesBeforeSync.map((n) => n.uuid)
const notesResults = await this.application.itemManager.findItems(expectedNotesUuids)
expect(notesResults.length).to.equal(notesBeforeSync.length)
for (const aNoteBeforeSync of notesBeforeSync) {
const noteResult = await this.application.itemManager.findItem(aNoteBeforeSync.uuid)
expect(aNoteBeforeSync.isItemContentEqualWith(noteResult)).to.equal(true)
}
})
it('changing password on one client should not invalidate other sessions', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const appA = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await appA.prepareForLaunch({})
await appA.launch(true)
const email = `${Math.random()}`
const password = `${Math.random()}`
await Factory.registerUserToApplication({
application: appA,
email: email,
password: password,
})
/** Create simultaneous appB signed into same account */
const appB = await Factory.createApplicationWithFakeCrypto('another-namespace')
await appB.prepareForLaunch({})
await appB.launch(true)
await Factory.loginToApplication({
application: appB,
email: email,
password: password,
})
/** Change password on appB */
const newPassword = 'random'
await appB.changePassword(password, newPassword)
/** Create an item and sync it */
const note = await Factory.createSyncedNote(appB)
/** Expect appA session to still be valid */
await appA.sync.sync()
expect(appA.items.findItem(note.uuid)).to.be.ok
await Factory.safeDeinit(appA)
await Factory.safeDeinit(appB)
})
it('should prompt user for account password and sign back in on invalid session', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const email = `${Math.random()}`
const password = `${Math.random()}`
let didPromptForSignIn = false
const receiveChallenge = async (challenge) => {
didPromptForSignIn = true
appA.submitValuesForChallenge(challenge, [
CreateChallengeValue(challenge.prompts[0], email),
CreateChallengeValue(challenge.prompts[1], password),
])
}
const appA = await Factory.createApplicationWithFakeCrypto(Factory.randomString())
await appA.prepareForLaunch({ receiveChallenge })
await appA.launch(true)
await Factory.registerUserToApplication({
application: appA,
email: email,
password: password,
})
const oldRootKey = await appA.protocolService.getRootKey()
/** Set the session as nonsense */
appA.apiService.session.accessToken = 'foo'
appA.apiService.session.refreshToken = 'bar'
/** Perform an authenticated network request */
await appA.sync.sync()
/** Allow session recovery to do its thing */
await Factory.sleep(5.0)
expect(didPromptForSignIn).to.equal(true)
expect(appA.apiService.session.accessToken).to.not.equal('foo')
expect(appA.apiService.session.refreshToken).to.not.equal('bar')
/** Expect that the session recovery replaces the global root key */
const newRootKey = await appA.protocolService.getRootKey()
expect(oldRootKey).to.not.equal(newRootKey)
await Factory.safeDeinit(appA)
})
it('should return current session in list of sessions', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const response = await this.application.apiService.getSessionsList()
expect(response.data[0].current).to.equal(true)
})
it('signing out should delete session from all list', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
/** Create new session aside from existing one */
const app2 = await Factory.createAndInitializeApplication('app2')
await app2.signIn(this.email, this.password)
const response = await this.application.apiService.getSessionsList()
expect(response.data.length).to.equal(2)
await app2.user.signOut()
const response2 = await this.application.apiService.getSessionsList()
expect(response2.data.length).to.equal(1)
})
it('revoking a session should destroy local data', async function () {
this.timeout(Factory.TwentySecondTimeout)
Factory.handlePasswordChallenges(this.application, this.password)
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const app2identifier = 'app2'
const app2 = await Factory.createAndInitializeApplication(app2identifier)
await app2.signIn(this.email, this.password)
const app2Deinit = new Promise((resolve) => {
app2.setOnDeinit(() => {
resolve()
})
})
const { data: sessions } = await this.application.getSessions()
const app2session = sessions.find((session) => !session.current)
await this.application.revokeSession(app2session.uuid)
void app2.sync.sync()
await app2Deinit
const deviceInterface = new WebDeviceInterface()
const payloads = await deviceInterface.getAllRawDatabasePayloads(app2identifier)
expect(payloads).to.be.empty
})
it('revoking other sessions should destroy their local data', async function () {
this.timeout(Factory.TwentySecondTimeout)
Factory.handlePasswordChallenges(this.application, this.password)
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const app2identifier = 'app2'
const app2 = await Factory.createAndInitializeApplication(app2identifier)
await app2.signIn(this.email, this.password)
const app2Deinit = new Promise((resolve) => {
app2.setOnDeinit(() => {
resolve()
})
})
await this.application.revokeAllOtherSessions()
void app2.sync.sync()
await app2Deinit
const deviceInterface = new WebDeviceInterface()
const payloads = await deviceInterface.getAllRawDatabasePayloads(app2identifier)
expect(payloads).to.be.empty
})
it('signing out with invalid session token should still delete local data', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
const invalidSession = this.application.apiService.getSession()
invalidSession.accessToken = undefined
invalidSession.refreshToken = undefined
const storageKey = this.application.diskStorageService.getPersistenceKey()
expect(localStorage.getItem(storageKey)).to.be.ok
await this.application.user.signOut()
expect(localStorage.getItem(storageKey)).to.not.be.ok
})
})

View File

@@ -0,0 +1,111 @@
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('settings service', function () {
const validSetting = SettingName.GoogleDriveBackupFrequency
const fakePayload = 'Im so meta even this acronym'
const updatedFakePayload = 'is meta'
let application
let context
beforeEach(async function () {
context = await Factory.createAppContextWithFakeCrypto()
await context.launch()
application = context.application
await Factory.registerUserToApplication({
application: context.application,
email: context.email,
password: context.password,
})
})
afterEach(async function () {
await Factory.safeDeinit(application)
})
it('creates and reads a setting', async function () {
await application.settings.updateSetting(validSetting, fakePayload)
const responseCreate = await application.settings.getSetting(validSetting)
expect(responseCreate).to.equal(fakePayload)
})
it('throws error on an invalid setting update', async function () {
const invalidSetting = 'FAKE_SETTING'
let caughtError = undefined
try {
await application.settings.updateSetting(invalidSetting, fakePayload)
} catch (error) {
caughtError = error
}
expect(caughtError).not.to.equal(undefined)
})
it('creates and lists settings', async function () {
await application.settings.updateSetting(validSetting, fakePayload)
const responseList = await application.settings.listSettings()
expect(responseList.getSettingValue(validSetting)).to.eql(fakePayload)
})
it('creates and deletes a setting', async function () {
await application.settings.updateSetting(validSetting, fakePayload)
const responseCreate = await application.settings.getSetting(validSetting)
expect(responseCreate).to.eql(fakePayload)
await application.settings.deleteSetting(validSetting)
const responseDeleted = await application.settings.listSettings()
expect(responseDeleted.getSettingValue(validSetting)).to.not.be.ok
})
it('creates and updates a setting', async function () {
await application.settings.updateSetting(validSetting, fakePayload)
await application.settings.updateSetting(validSetting, updatedFakePayload)
const responseUpdated = await application.settings.getSetting(validSetting)
expect(responseUpdated).to.eql(updatedFakePayload)
})
it('reads a nonexistent setting', async () => {
const setting = await application.settings.getSetting(validSetting)
expect(setting).to.equal(undefined)
})
it('reads a nonexistent sensitive setting', async () => {
const setting = await application.settings.getDoesSensitiveSettingExist(SettingName.MfaSecret)
expect(setting).to.equal(false)
})
it('creates and reads a sensitive setting', async () => {
await application.settings.updateSetting(SettingName.MfaSecret, 'fake_secret', true)
const setting = await application.settings.getDoesSensitiveSettingExist(SettingName.MfaSecret)
expect(setting).to.equal(true)
})
it('creates and lists a sensitive setting', async () => {
await application.settings.updateSetting(SettingName.MfaSecret, 'fake_secret', true)
await application.settings.updateSetting(SettingName.MuteFailedBackupsEmails, MuteFailedBackupsEmailsOption.Muted)
const settings = await application.settings.listSettings()
expect(settings.getSettingValue(SettingName.MuteFailedBackupsEmails)).to.eql(MuteFailedBackupsEmailsOption.Muted)
expect(settings.getSettingValue(SettingName.MfaSecret)).to.not.be.ok
})
it('reads a subscription setting', async () => {
await Factory.publishMockedEvent('SUBSCRIPTION_PURCHASED', {
userEmail: context.email,
subscriptionId: 1,
subscriptionName: 'PRO_PLAN',
subscriptionExpiresAt: (new Date().getTime() + 3_600_000) * 1_000,
timestamp: Date.now(),
offline: false,
})
await Factory.sleep(0.5)
const setting = await application.settings.getSubscriptionSetting('FILE_UPLOAD_BYTES_LIMIT')
expect(setting).to.be.a('string')
})
})

View File

@@ -0,0 +1,49 @@
<html>
<body>
<script>
const SHUFFLE = false;
const SEED = (Math.random() * 100) / 100;
if (SHUFFLE) {
console.warn("Shuffling tests with seed:", SEED);
}
const run = Mocha.prototype.run;
const each = Mocha.Suite.prototype.eachTest;
Mocha.prototype.run = function () {
shuffle(this.files);
return run.apply(this, arguments);
};
Mocha.Suite.prototype.eachTest = function () {
shuffle(this.tests);
shuffle(this.suites);
return each.apply(this, arguments);
};
function random() {
var x = Math.sin(SEED++) * 10000;
return x - Math.floor(x);
}
function shuffle(array) {
if (!SHUFFLE) {
return array;
}
if (array == null || !array.length) return;
let index = -1;
const length = array.length;
const lastIndex = length - 1;
while (++index < length) {
const rand = index + Math.floor(random() * (lastIndex - index + 1));
const value = array[rand];
array[rand] = array[index];
array[index] = value;
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,345 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
import WebDeviceInterface from './lib/web_device_interface.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('singletons', function () {
this.timeout(Factory.TenSecondTimeout)
const syncOptions = {
checkIntegrity: true,
}
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
function createPrefsPayload() {
const params = {
uuid: UuidGenerator.GenerateUuid(),
content_type: ContentType.UserPrefs,
content: {
foo: 'bar',
},
}
return new DecryptedPayload(params)
}
function findOrCreatePrefsSingleton(application) {
return application.singletonManager.findOrCreateContentTypeSingleton(ContentType.UserPrefs, FillItemContent({}))
}
beforeEach(async function () {
localStorage.clear()
this.expectedItemCount = BASE_ITEM_COUNT
this.application = await Factory.createInitAppWithFakeCrypto()
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
this.registerUser = async () => {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
}
this.signOut = async () => {
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
}
this.signIn = async () => {
await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true)
}
this.extManagerId = 'org.standardnotes.extensions-manager'
this.extPred = new CompoundPredicate('and', [
new Predicate('content_type', '=', ContentType.Component),
new Predicate('package_info.identifier', '=', this.extManagerId),
])
this.createExtMgr = () => {
return this.application.itemManager.createItem(
ContentType.Component,
{
package_info: {
name: 'Extensions',
identifier: this.extManagerId,
},
},
true,
)
}
})
afterEach(async function () {
expect(this.application.syncService.isOutOfSync()).to.equal(false)
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
const rawPayloads = await this.application.diskStorageService.getAllRawPayloads()
expect(rawPayloads.length).to.equal(this.expectedItemCount)
await Factory.safeDeinit(this.application)
localStorage.clear()
})
it(`only resolves to ${BASE_ITEM_COUNT} items`, async function () {
/** Preferences are an item we know to always return true for isSingleton */
const prefs1 = createPrefsPayload()
const prefs2 = createPrefsPayload()
const prefs3 = createPrefsPayload()
const items = await this.application.itemManager.emitItemsFromPayloads(
[prefs1, prefs2, prefs3],
PayloadEmitSource.LocalChanged,
)
await this.application.itemManager.setItemsDirty(items)
await this.application.syncService.sync(syncOptions)
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
})
it('duplicate components should auto-resolve to 1', async function () {
const extManager = await this.createExtMgr()
this.expectedItemCount += 1
/** Call needlessly */
await this.createExtMgr()
await this.createExtMgr()
await this.createExtMgr()
expect(extManager).to.be.ok
const refreshedExtMgr = this.application.items.findItem(extManager.uuid)
expect(refreshedExtMgr).to.be.ok
await this.application.sync.sync(syncOptions)
expect(this.application.itemManager.itemsMatchingPredicate(ContentType.Component, this.extPred).length).to.equal(1)
})
it('resolves via find or create', async function () {
/* Set to never synced as singleton manager will attempt to sync before resolving */
this.application.syncService.ut_clearLastSyncDate()
this.application.syncService.ut_setDatabaseLoaded(false)
const contentType = ContentType.UserPrefs
const predicate = new Predicate('content_type', '=', contentType)
/* Start a sync right after we await singleton resolve below */
setTimeout(() => {
this.application.syncService.ut_setDatabaseLoaded(true)
this.application.sync.sync({
/* Simulate the first sync occuring as that is handled specially by sync service */
mode: SyncMode.DownloadFirst,
})
})
const userPreferences = await this.application.singletonManager.findOrCreateContentTypeSingleton(contentType, {})
expect(userPreferences).to.be.ok
const refreshedUserPrefs = this.application.items.findItem(userPreferences.uuid)
expect(refreshedUserPrefs).to.be.ok
await this.application.sync.sync(syncOptions)
expect(this.application.itemManager.itemsMatchingPredicate(contentType, predicate).length).to.equal(1)
})
it('resolves registered predicate with signing in/out', async function () {
await this.registerUser()
await this.signOut()
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
await this.createExtMgr()
this.expectedItemCount += 1
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
await this.signOut()
await this.createExtMgr()
await this.application.sync.sync(syncOptions)
const extraSync = this.application.sync.sync(syncOptions)
await this.signIn()
await extraSync
}).timeout(15000)
it('singletons that are deleted after download first sync should not sync to server', async function () {
await this.registerUser()
await this.createExtMgr()
await this.createExtMgr()
await this.createExtMgr()
this.expectedItemCount++
let didCompleteRelevantSync = false
let beginCheckingResponse = false
this.application.syncService.addEventObserver(async (eventName, data) => {
if (eventName === SyncEvent.DownloadFirstSyncCompleted) {
beginCheckingResponse = true
}
if (!beginCheckingResponse) {
return
}
if (!didCompleteRelevantSync && eventName === SyncEvent.SingleRoundTripSyncCompleted) {
didCompleteRelevantSync = true
const saved = data.savedPayloads
expect(saved.length).to.equal(1)
const matching = saved.find((p) => p.content_type === ContentType.Component && p.deleted)
expect(matching).to.not.be.ok
}
})
await this.application.syncService.sync({ mode: SyncMode.DownloadFirst })
expect(didCompleteRelevantSync).to.equal(true)
}).timeout(10000)
it('signing into account and retrieving singleton shouldnt put us in deadlock', async function () {
await this.registerUser()
/** Create prefs */
const ogPrefs = await findOrCreatePrefsSingleton(this.application)
await this.application.sync.sync(syncOptions)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
/** Create another instance while signed out */
await findOrCreatePrefsSingleton(this.application)
await Factory.loginToApplication({
application: this.application,
email: this.email,
password: this.password,
})
/** After signing in, the instance retrieved from the server should be the one kept */
const latestPrefs = await findOrCreatePrefsSingleton(this.application)
expect(latestPrefs.uuid).to.equal(ogPrefs.uuid)
const allPrefs = this.application.itemManager.getItems(ogPrefs.content_type)
expect(allPrefs.length).to.equal(1)
})
it('resolving singleton before first sync, then signing in, should result in correct number of instances', async function () {
await this.registerUser()
/** Create prefs and associate them with account */
const ogPrefs = await findOrCreatePrefsSingleton(this.application)
await this.application.sync.sync(syncOptions)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
/** Create another instance while signed out */
await findOrCreatePrefsSingleton(this.application)
await Factory.loginToApplication({
application: this.application,
email: this.email,
password: this.password,
})
/** After signing in, the instance retrieved from the server should be the one kept */
const latestPrefs = await findOrCreatePrefsSingleton(this.application)
expect(latestPrefs.uuid).to.equal(ogPrefs.uuid)
const allPrefs = this.application.itemManager.getItems(ogPrefs.content_type)
expect(allPrefs.length).to.equal(1)
})
it('if only result is errorDecrypting, create new item', async function () {
const item = this.application.itemManager.items.find((item) => item.content_type === ContentType.UserPrefs)
const erroredPayload = new EncryptedPayload({
...item.payload.ejected(),
content: '004:...',
errorDecrypting: true,
})
await this.application.payloadManager.emitPayload(erroredPayload)
const resolvedItem = await this.application.singletonManager.findOrCreateContentTypeSingleton(
item.content_type,
item.content,
)
await this.application.sync.sync({ awaitAll: true })
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
expect(resolvedItem.uuid).to.not.equal(item.uuid)
expect(resolvedItem.errorDecrypting).to.not.be.ok
})
it('if two items and one is error decrypting, should resolve after download first sync', async function () {
/**
* While signing in, a singleton item may be inserted that hasn't yet had the chance to decrypt
* When the singleton logic runs, it will ignore this item, and matching singletons will result
* in just 1, meaning the two items will not be consolidated. We want to make sure that when the item
* is then subsequently decrypted, singleton logic runs again for the item.
*/
const sharedContent = {
package_info: {
name: 'Extensions',
identifier: this.extManagerId,
},
}
const errorDecryptingFalse = false
await Factory.insertItemWithOverride(
this.application,
ContentType.Component,
sharedContent,
true,
errorDecryptingFalse,
)
const errorDecryptingTrue = true
const errored = await Factory.insertItemWithOverride(
this.application,
ContentType.Component,
sharedContent,
true,
errorDecryptingTrue,
)
this.expectedItemCount += 1
await this.application.sync.sync(syncOptions)
/** Now mark errored as not errorDecrypting and sync */
const notErrored = new DecryptedPayload({
...errored.payload,
content: sharedContent,
errorDecrypting: false,
})
await this.application.payloadManager.emitPayload(notErrored)
/** Item will get decrypted on current tick, so wait one before syncing */
await Factory.sleep(0)
await this.application.syncService.sync(syncOptions)
expect(this.application.itemManager.itemsMatchingPredicate(ContentType.Component, this.extPred).length).to.equal(1)
})
it('alternating the uuid of a singleton should return correct result', async function () {
const payload = createPrefsPayload()
const item = await this.application.itemManager.emitItemFromPayload(payload, PayloadEmitSource.LocalChanged)
await this.application.syncService.sync(syncOptions)
const predicate = new Predicate('content_type', '=', item.content_type)
let resolvedItem = await this.application.singletonManager.findOrCreateContentTypeSingleton(
payload.content_type,
payload.content,
)
const originalUuid = resolvedItem.uuid
await Factory.alternateUuidForItem(this.application, resolvedItem.uuid)
await this.application.syncService.sync(syncOptions)
const resolvedItem2 = await this.application.singletonManager.findOrCreateContentTypeSingleton(
payload.content_type,
payload.content,
)
resolvedItem = this.application.items.findItem(resolvedItem.uuid)
expect(resolvedItem).to.not.be.ok
expect(resolvedItem2.uuid).to.not.equal(originalUuid)
expect(this.application.itemManager.items.length).to.equal(this.expectedItemCount)
})
})

View File

@@ -0,0 +1,304 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('storage manager', function () {
this.timeout(Factory.TenSecondTimeout)
/**
* Items are saved in localStorage in tests.
* Base keys are `storage`, `snjs_version`, and `keychain`
*/
const BASE_KEY_COUNT = 3
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
beforeEach(async function () {
localStorage.clear()
this.expectedKeyCount = BASE_KEY_COUNT
this.application = await Factory.createInitAppWithFakeCrypto(Environment.Mobile)
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
localStorage.clear()
})
it('should set and retrieve values', async function () {
const key = 'foo'
const value = 'bar'
await this.application.diskStorageService.setValue(key, value)
expect(await this.application.diskStorageService.getValue(key)).to.eql(value)
})
it('should set and retrieve items', async function () {
const payload = Factory.createNotePayload()
await this.application.diskStorageService.savePayload(payload)
const payloads = await this.application.diskStorageService.getAllRawPayloads()
expect(payloads.length).to.equal(BASE_ITEM_COUNT + 1)
})
it('should clear values', async function () {
const key = 'foo'
const value = 'bar'
await this.application.diskStorageService.setValue(key, value)
await this.application.diskStorageService.clearAllData()
expect(await this.application.diskStorageService.getValue(key)).to.not.be.ok
})
it('serverPassword should not be saved to keychain', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: false,
})
const keychainValue = await this.application.deviceInterface.getNamespacedKeychainValue(this.application.identifier)
expect(keychainValue.masterKey).to.be.ok
expect(keychainValue.serverPassword).to.not.be.ok
})
it.skip('regular session should persist data', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: false,
})
const key = 'foo'
const value = 'bar'
await this.application.diskStorageService.setValue(key, value)
/** Items are stored in local storage */
expect(Object.keys(localStorage).length).to.equal(this.expectedKeyCount + BASE_ITEM_COUNT)
const retrievedValue = await this.application.diskStorageService.getValue(key)
expect(retrievedValue).to.equal(value)
})
it('ephemeral session should not persist data', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: true,
})
const key = 'foo'
const value = 'bar'
await this.application.diskStorageService.setValueAndAwaitPersist(key, value)
expect(Object.keys(localStorage).length).to.equal(0)
const retrievedValue = await this.application.diskStorageService.getValue(key)
expect(retrievedValue).to.equal(value)
})
it('ephemeral session should not persist to database', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: true,
})
await Factory.createSyncedNote(this.application)
const rawPayloads = await this.application.diskStorageService.getAllRawPayloads()
expect(rawPayloads.length).to.equal(0)
})
it('storage with no account and no passcode should not be encrypted', async function () {
await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar')
const wrappedValue = this.application.diskStorageService.values[ValueModesKeys.Wrapped]
const payload = new DecryptedPayload(wrappedValue)
expect(payload.content).to.be.an.instanceof(Object)
})
it('storage aftering adding passcode should be encrypted', async function () {
await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar')
await this.application.addPasscode('123')
const wrappedValue = this.application.diskStorageService.values[ValueModesKeys.Wrapped]
const payload = new EncryptedPayload(wrappedValue)
expect(payload.content).to.be.a('string')
})
it('storage after adding passcode then removing passcode should not be encrypted', async function () {
const passcode = '123'
Factory.handlePasswordChallenges(this.application, passcode)
await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar')
await this.application.addPasscode(passcode)
await this.application.diskStorageService.setValueAndAwaitPersist('bar', 'foo')
await this.application.removePasscode()
const wrappedValue = this.application.diskStorageService.values[ValueModesKeys.Wrapped]
const payload = new DecryptedPayload(wrappedValue)
expect(payload.content).to.be.an.instanceof(Object)
})
it('storage aftering adding passcode/removing passcode w/account should be encrypted', async function () {
const passcode = '123'
/**
* After setting passcode, we expect that the keychain has been cleared, as the account keys
* are now wrapped in storage with the passcode. Once the passcode is removed, we expect
* the account keys to be moved to the keychain.
* */
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
expect(await this.application.deviceInterface.getNamespacedKeychainValue(this.application.identifier)).to.be.ok
await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar')
Factory.handlePasswordChallenges(this.application, this.password)
await this.application.addPasscode(passcode)
expect(await this.application.deviceInterface.getNamespacedKeychainValue(this.application.identifier)).to.not.be.ok
await this.application.diskStorageService.setValueAndAwaitPersist('bar', 'foo')
Factory.handlePasswordChallenges(this.application, passcode)
await this.application.removePasscode()
expect(await this.application.deviceInterface.getNamespacedKeychainValue(this.application.identifier)).to.be.ok
const wrappedValue = this.application.diskStorageService.values[ValueModesKeys.Wrapped]
const payload = new EncryptedPayload(wrappedValue)
expect(payload.content).to.be.a('string')
})
it('adding account should encrypt storage with account keys', async function () {
await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar')
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: true,
})
const accountKey = await this.application.protocolService.getRootKey()
expect(await this.application.diskStorageService.canDecryptWithKey(accountKey)).to.equal(true)
})
it('signing out of account should decrypt storage', async function () {
await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar')
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: true,
})
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
await this.application.diskStorageService.setValueAndAwaitPersist('bar', 'foo')
const wrappedValue = this.application.diskStorageService.values[ValueModesKeys.Wrapped]
const payload = new DecryptedPayload(wrappedValue)
expect(payload.content).to.be.an.instanceof(Object)
})
it('adding account then passcode should encrypt storage with account keys', async function () {
/** Should encrypt storage with account keys and encrypt account keys with passcode */
await this.application.diskStorageService.setValueAndAwaitPersist('foo', 'bar')
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: true,
})
/** Should not be wrapped root key yet */
expect(await this.application.protocolService.rootKeyEncryption.getWrappedRootKey()).to.not.be.ok
const passcode = '123'
Factory.handlePasswordChallenges(this.application, this.password)
await this.application.addPasscode(passcode)
await this.application.diskStorageService.setValueAndAwaitPersist('bar', 'foo')
/** Root key should now be wrapped */
expect(await this.application.protocolService.rootKeyEncryption.getWrappedRootKey()).to.be.ok
const accountKey = await this.application.protocolService.getRootKey()
expect(await this.application.diskStorageService.canDecryptWithKey(accountKey)).to.equal(true)
const passcodeKey = await this.application.protocolService.computeWrappingKey(passcode)
const wrappedRootKey = await this.application.protocolService.rootKeyEncryption.getWrappedRootKey()
/** Expect that we can decrypt wrapped root key with passcode key */
const payload = new EncryptedPayload(wrappedRootKey)
const decrypted = await this.application.protocolService.decryptSplitSingle({
usesRootKey: {
items: [payload],
key: passcodeKey,
},
})
expect(decrypted.content).to.be.an.instanceof(Object)
})
it('disabling storage encryption should store items without encryption', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: false,
})
await this.application.setStorageEncryptionPolicy(StorageEncryptionPolicy.Disabled)
const payloads = await this.application.diskStorageService.getAllRawPayloads()
const payload = payloads[0]
expect(typeof payload.content).to.not.equal('string')
expect(payload.content.references).to.be.ok
const identifier = this.application.identifier
const app = await Factory.createAndInitializeApplication(identifier, Environment.Mobile)
expect(app.diskStorageService.encryptionPolicy).to.equal(StorageEncryptionPolicy.Disabled)
})
it('stored payloads should not contain metadata fields', async function () {
await this.application.addPasscode('123')
await Factory.createSyncedNote(this.application)
const payloads = await this.application.diskStorageService.getAllRawPayloads()
const payload = payloads[0]
expect(payload.fields).to.not.be.ok
expect(payload.source).to.not.be.ok
expect(payload.format).to.not.be.ok
})
it('storing an offline synced payload should not include dirty flag', async function () {
await this.application.addPasscode('123')
await Factory.createSyncedNote(this.application)
const payloads = await this.application.diskStorageService.getAllRawPayloads()
const payload = payloads[0]
expect(payload.dirty).to.not.be.ok
})
it('storing an online synced payload should not include dirty flag', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: false,
})
await Factory.createSyncedNote(this.application)
const payloads = await this.application.diskStorageService.getAllRawPayloads()
const payload = payloads[0]
expect(payload.dirty).to.not.be.ok
})
it('signing out should clear unwrapped value store', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: false,
})
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
const values = this.application.diskStorageService.values[ValueModesKeys.Unwrapped]
expect(Object.keys(values).length).to.equal(0)
})
it('signing out should clear payloads', async function () {
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
ephemeral: false,
})
await Factory.createSyncedNote(this.application)
expect(await Factory.storagePayloadCount(this.application)).to.equal(BASE_ITEM_COUNT + 1)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
expect(await Factory.storagePayloadCount(this.application)).to.equal(BASE_ITEM_COUNT)
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('sync integrity', () => {
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
before(function () {
localStorage.clear()
})
after(function () {
localStorage.clear()
})
beforeEach(async function () {
this.expectedItemCount = BASE_ITEM_COUNT
this.application = await Factory.createInitAppWithFakeCrypto()
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
await Factory.registerUserToApplication({
application: this.application,
email: this.email,
password: this.password,
})
})
const awaitSyncEventPromise = (application, targetEvent) => {
return new Promise((resolve) => {
application.syncService.addEventObserver((event) => {
if (event === targetEvent) {
resolve()
}
})
})
}
afterEach(async function () {
expect(this.application.syncService.isOutOfSync()).to.equal(false)
const rawPayloads = await this.application.diskStorageService.getAllRawPayloads()
expect(rawPayloads.length).to.equal(this.expectedItemCount)
await Factory.safeDeinit(this.application)
})
it('should detect when out of sync', async function () {
const item = await this.application.itemManager.emitItemFromPayload(
Factory.createNotePayload(),
PayloadEmitSource.LocalChanged,
)
this.expectedItemCount++
const didEnterOutOfSync = awaitSyncEventPromise(this.application, SyncEvent.EnterOutOfSync)
await this.application.syncService.sync({ checkIntegrity: true })
await this.application.itemManager.removeItemLocally(item)
await this.application.syncService.sync({ checkIntegrity: true, awaitAll: true })
await didEnterOutOfSync
})
it('should self heal after out of sync', async function () {
const item = await this.application.itemManager.emitItemFromPayload(
Factory.createNotePayload(),
PayloadEmitSource.LocalChanged,
)
this.expectedItemCount++
const didEnterOutOfSync = awaitSyncEventPromise(this.application, SyncEvent.EnterOutOfSync)
const didExitOutOfSync = awaitSyncEventPromise(this.application, SyncEvent.ExitOutOfSync)
await this.application.syncService.sync({ checkIntegrity: true })
await this.application.itemManager.removeItemLocally(item)
await this.application.syncService.sync({ checkIntegrity: true, awaitAll: true })
await Promise.all([didEnterOutOfSync, didExitOutOfSync])
expect(this.application.syncService.isOutOfSync()).to.equal(false)
})
})

View File

@@ -0,0 +1,168 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
import { createRelatedNoteTagPairPayload } from '../lib/Items.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('notes + tags syncing', function () {
const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
after(function () {
localStorage.clear()
})
beforeEach(async function () {
this.application = await Factory.createInitAppWithFakeCrypto()
Factory.disableIntegrityAutoHeal(this.application)
const email = UuidGenerator.GenerateUuid()
const password = UuidGenerator.GenerateUuid()
await Factory.registerUserToApplication({
application: this.application,
email,
password,
})
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
})
it('syncing an item then downloading it should include items_key_id', async function () {
const note = await Factory.createMappedNote(this.application)
await this.application.itemManager.setItemDirty(note)
await this.application.syncService.sync(syncOptions)
await this.application.payloadManager.resetState()
await this.application.itemManager.resetState()
await this.application.syncService.clearSyncPositionTokens()
await this.application.syncService.sync(syncOptions)
const downloadedNote = this.application.itemManager.getDisplayableNotes()[0]
expect(downloadedNote.items_key_id).to.not.be.ok
// Allow time for waitingForKey
await Factory.sleep(0.1)
expect(downloadedNote.title).to.be.ok
expect(downloadedNote.content.text).to.be.ok
})
it('syncing a note many times does not cause duplication', async function () {
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged)
const note = this.application.itemManager.getItems([ContentType.Note])[0]
const tag = this.application.itemManager.getItems([ContentType.Tag])[0]
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1)
expect(this.application.itemManager.getDisplayableTags().length).to.equal(1)
for (let i = 0; i < 9; i++) {
await this.application.itemManager.setItemsDirty([note, tag])
await this.application.syncService.sync(syncOptions)
this.application.syncService.clearSyncPositionTokens()
expect(tag.content.references.length).to.equal(1)
expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1)
expect(tag.noteCount).to.equal(1)
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1)
expect(this.application.itemManager.getDisplayableTags().length).to.equal(1)
console.warn('Waiting 0.1s...')
await Factory.sleep(0.1)
}
}).timeout(20000)
it('handles signing in and merging data', async function () {
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged)
const originalNote = this.application.itemManager.getDisplayableNotes()[0]
const originalTag = this.application.itemManager.getDisplayableTags()[0]
await this.application.itemManager.setItemsDirty([originalNote, originalTag])
await this.application.syncService.sync(syncOptions)
expect(originalTag.content.references.length).to.equal(1)
expect(originalTag.noteCount).to.equal(1)
expect(this.application.itemManager.itemsReferencingItem(originalNote).length).to.equal(1)
// when signing in, all local items are cleared from storage (but kept in memory; to clear desktop logs),
// then resaved with alternated uuids.
await this.application.diskStorageService.clearAllPayloads()
await this.application.syncService.markAllItemsAsNeedingSyncAndPersist()
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1)
expect(this.application.itemManager.getDisplayableTags().length).to.equal(1)
const note = this.application.itemManager.getDisplayableNotes()[0]
const tag = this.application.itemManager.getDisplayableTags()[0]
expect(tag.content.references.length).to.equal(1)
expect(note.content.references.length).to.equal(0)
expect(tag.noteCount).to.equal(1)
expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1)
})
it('duplicating a tag should maintian its relationships', async function () {
const pair = createRelatedNoteTagPairPayload()
const notePayload = pair[0]
const tagPayload = pair[1]
await this.application.itemManager.emitItemsFromPayloads([notePayload, tagPayload], PayloadEmitSource.LocalChanged)
let note = this.application.itemManager.getDisplayableNotes()[0]
let tag = this.application.itemManager.getDisplayableTags()[0]
expect(this.application.itemManager.itemsReferencingItem(note).length).to.equal(1)
await this.application.itemManager.setItemsDirty([note, tag])
await this.application.syncService.sync(syncOptions)
await this.application.syncService.clearSyncPositionTokens()
note = this.application.itemManager.findItem(note.uuid)
tag = this.application.itemManager.findItem(tag.uuid)
expect(note.dirty).to.equal(false)
expect(tag.dirty).to.equal(false)
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1)
expect(this.application.itemManager.getDisplayableTags().length).to.equal(1)
await Factory.changePayloadTimeStampAndSync(
this.application,
tag.payload,
Factory.dateToMicroseconds(Factory.yesterday()),
{
title: `${Math.random()}`,
},
syncOptions,
)
tag = this.application.itemManager.findItem(tag.uuid)
// tag should now be conflicted and a copy created
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1)
expect(this.application.itemManager.getDisplayableTags().length).to.equal(2)
const tags = this.application.itemManager.getDisplayableTags()
const conflictedTag = tags.find((tag) => {
return !!tag.content.conflict_of
})
const originalTag = tags.find((tag) => {
return tag !== conflictedTag
})
expect(conflictedTag.uuid).to.not.equal(originalTag.uuid)
expect(originalTag.uuid).to.equal(tag.uuid)
expect(conflictedTag.content.conflict_of).to.equal(originalTag.uuid)
expect(conflictedTag.noteCount).to.equal(originalTag.noteCount)
expect(this.application.itemManager.itemsReferencingItem(conflictedTag).length).to.equal(0)
expect(this.application.itemManager.itemsReferencingItem(originalTag).length).to.equal(0)
// Two tags now link to this note
const referencingItems = this.application.itemManager.itemsReferencingItem(note)
expect(referencingItems.length).to.equal(2)
expect(referencingItems[0]).to.not.equal(referencingItems[1])
}).timeout(10000)
})

View File

@@ -0,0 +1,91 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from '../lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('offline syncing', () => {
const BASE_ITEM_COUNT = 2 /** Default items key, user preferences */
const syncOptions = {
checkIntegrity: true,
awaitAll: true,
}
beforeEach(async function () {
this.expectedItemCount = BASE_ITEM_COUNT
this.application = await Factory.createInitAppWithFakeCrypto()
})
afterEach(async function () {
expect(this.application.syncService.isOutOfSync()).to.equal(false)
await Factory.safeDeinit(this.application)
})
before(async function () {
localStorage.clear()
})
after(async function () {
localStorage.clear()
})
it('should sync item with no passcode', async function () {
let note = await Factory.createMappedNote(this.application)
expect(this.application.itemManager.getDirtyItems().length).to.equal(1)
const rawPayloads1 = await this.application.diskStorageService.getAllRawPayloads()
expect(rawPayloads1.length).to.equal(this.expectedItemCount)
await this.application.syncService.sync(syncOptions)
note = this.application.items.findItem(note.uuid)
/** In rare cases a sync can complete so fast that the dates are equal; this is ok. */
expect(note.lastSyncEnd).to.be.at.least(note.lastSyncBegan)
this.expectedItemCount++
expect(this.application.itemManager.getDirtyItems().length).to.equal(0)
const rawPayloads2 = await this.application.diskStorageService.getAllRawPayloads()
expect(rawPayloads2.length).to.equal(this.expectedItemCount)
const itemsKeyRaw = (await Factory.getStoragePayloadsOfType(this.application, ContentType.ItemsKey))[0]
const noteRaw = (await Factory.getStoragePayloadsOfType(this.application, ContentType.Note))[0]
/** Encrypts with default items key */
expect(typeof noteRaw.content).to.equal('string')
/** Not encrypted as no passcode/root key */
expect(typeof itemsKeyRaw.content).to.equal('object')
})
it('should sync item encrypted with passcode', async function () {
await this.application.addPasscode('foobar')
await Factory.createMappedNote(this.application)
expect(this.application.itemManager.getDirtyItems().length).to.equal(1)
const rawPayloads1 = await this.application.diskStorageService.getAllRawPayloads()
expect(rawPayloads1.length).to.equal(this.expectedItemCount)
await this.application.syncService.sync(syncOptions)
this.expectedItemCount++
expect(this.application.itemManager.getDirtyItems().length).to.equal(0)
const rawPayloads2 = await this.application.diskStorageService.getAllRawPayloads()
expect(rawPayloads2.length).to.equal(this.expectedItemCount)
const payload = rawPayloads2[0]
expect(typeof payload.content).to.equal('string')
expect(payload.content.startsWith(this.application.protocolService.getLatestVersion())).to.equal(true)
})
it('signing out while offline should succeed', async function () {
await Factory.createMappedNote(this.application)
this.expectedItemCount++
await this.application.syncService.sync(syncOptions)
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
expect(this.application.noAccount()).to.equal(true)
expect(this.application.getUser()).to.not.be.ok
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
<html>
<head>
<meta charset="utf-8">
<title>Mocha Tests</title>
<link href="assets/mocha.css" rel="stylesheet" />
<script src="../../../.yarn/unplugged/chai-npm-4.3.6-dba90e4b0b/node_modules/chai/chai.js"></script>
<script src="./vendor/chai-as-promised-built.js"></script>
<script src="../../../.yarn/unplugged/regenerator-runtime-npm-0.13.9-6d02340eec/node_modules/regenerator-runtime/runtime.js"></script>
<script src="../../../.yarn/unplugged/mocha-npm-9.2.2-f7735febb8/node_modules/mocha/mocha.js"></script>
<script src="../../../.yarn/unplugged/chai-subset-npm-1.6.0-3cee47a65d/node_modules/chai-subset/lib/chai-subset.js"></script>
<script src="../../sncrypto-web/dist//sncrypto-web.js"></script>
<script src="../../../.yarn/unplugged/sinon-npm-13.0.2-8544b59862/node_modules/sinon/pkg/sinon.js"></script>
<script src="../dist/snjs.js"></script>
<script>
const urlParams = new URLSearchParams(window.location.search);
const syncServerHostName = urlParams.get('sync_server_host_name') ?? 'syncing-server-proxy';
const bail = urlParams.get('bail') === 'false' ? false : true;
window._default_sync_server = `http://${syncServerHostName}:3123`;
Object.assign(window, SNCrypto);
Object.assign(window, SNLibrary);
SNLog.onLog = (message) => {
console.log(message);
};
SNLog.onError = (error) => {
console.error(error);
};
mocha.setup('bdd');
mocha.timeout(5000);
mocha.bail(bail);
</script>
<script type="module" src="memory.test.js"></script>
<script type="module" src="protocol.test.js"></script>
<script type="module" src="utils.test.js"></script>
<script type="module" src="000.test.js"></script>
<script type="module" src="001.test.js"></script>
<script type="module" src="002.test.js"></script>
<script type="module" src="003.test.js"></script>
<script type="module" src="004.test.js"></script>
<script type="module" src="workspaces.test.js"></script>
<script type="module" src="app-group.test.js"></script>
<script type="module" src="application.test.js"></script>
<script type="module" src="payload.test.js"></script>
<script type="module" src="payload_encryption.test.js"></script>
<script type="module" src="item.test.js"></script>
<script type="module" src="item_manager.test.js"></script>
<script type="module" src="features.test.js"></script>
<script type="module" src="settings.test.js"></script>
<script type="module" src="mfa_service.test.js"></script>
<script type="module" src="mutator.test.js"></script>
<script type="module" src="payload_manager.test.js"></script>
<script type="module" src="collections.test.js"></script>
<script type="module" src="note_display_criteria.test.js"></script>
<script type="module" src="keys.test.js"></script>
<script type="module" src="key_params.test.js"></script>
<script type="module" src="key_recovery_service.test.js"></script>
<script type="module" src="backups.test.js"></script>
<script type="module" src="upgrading.test.js"></script>
<script type="module" src="model_tests/importing.test.js"></script>
<script type="module" src="model_tests/appmodels.test.js"></script>
<script type="module" src="model_tests/items.test.js"></script>
<script type="module" src="model_tests/mapping.test.js"></script>
<script type="module" src="model_tests/notes_smart_tags.test.js"></script>
<script type="module" src="model_tests/notes_tags.test.js"></script>
<script type="module" src="model_tests/notes_tags_folders.test.js"></script>
<script type="module" src="model_tests/performance.test.js"></script>
<script type="module" src="sync_tests/offline.test.js"></script>
<script type="module" src="sync_tests/notes_tags.test.js"></script>
<script type="module" src="sync_tests/online.test.js"></script>
<script type="module" src="sync_tests/conflicting.test.js"></script>
<script type="module" src="sync_tests/integrity.test.js"></script>
<script type="module" src="auth-fringe-cases.test.js"></script>
<script type="module" src="auth.test.js"></script>
<script type="module" src="device_auth.test.js"></script>
<script type="module" src="storage.test.js"></script>
<script type="module" src="protection.test.js"></script>
<script type="module" src="singletons.test.js"></script>
<script type="module" src="migrations/migration.test.js"></script>
<script type="module" src="migrations/2020-01-15-web.test.js"></script>
<script type="module" src="migrations/2020-01-15-mobile.test.js"></script>
<script type="module" src="migrations/tags-to-folders.test.js"></script>
<script type="module" src="history.test.js"></script>
<script type="module" src="actions.test.js"></script>
<script type="module" src="preferences.test.js"></script>
<script type="module" src="session-sharing.test.js"></script>
<script type="module" src="files.test.js"></script>
<script type="module" src="session.test.js"></script>
<script type="module">
mocha.run();
</script>
</head>
<body>
<div id="mocha"></div>
</body>
</html>

View File

@@ -0,0 +1,282 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe('upgrading', () => {
beforeEach(async function () {
localStorage.clear()
this.application = await Factory.createInitAppWithFakeCrypto()
this.email = UuidGenerator.GenerateUuid()
this.password = UuidGenerator.GenerateUuid()
this.passcode = '1234'
const promptValueReply = (prompts) => {
const values = []
for (const prompt of prompts) {
if (prompt.validation === ChallengeValidation.LocalPasscode) {
values.push(CreateChallengeValue(prompt, this.passcode))
} else {
values.push(CreateChallengeValue(prompt, this.password))
}
}
return values
}
this.receiveChallenge = (challenge) => {
void this.receiveChallengeWithApp(this.application, challenge)
}
this.receiveChallengeWithApp = (application, challenge) => {
application.addChallengeObserver(challenge, {
onInvalidValue: (value) => {
const values = promptValueReply([value.prompt])
application.submitValuesForChallenge(challenge, values)
numPasscodeAttempts++
},
})
const initialValues = promptValueReply(challenge.prompts)
application.submitValuesForChallenge(challenge, initialValues)
}
})
afterEach(async function () {
await Factory.safeDeinit(this.application)
localStorage.clear()
})
it('upgrade should be available when account only', async function () {
const oldVersion = ProtocolVersion.V003
/** Register with 003 version */
await Factory.registerOldUser({
application: this.application,
email: this.email,
password: this.password,
version: oldVersion,
})
expect(await this.application.protocolUpgradeAvailable()).to.equal(true)
})
it('upgrade should be available when passcode only', async function () {
const oldVersion = ProtocolVersion.V003
await Factory.setOldVersionPasscode({
application: this.application,
passcode: this.passcode,
version: oldVersion,
})
expect(await this.application.protocolUpgradeAvailable()).to.equal(true)
})
it('upgrades application protocol from 003 to 004', async function () {
const oldVersion = ProtocolVersion.V003
const newVersion = ProtocolVersion.V004
await Factory.createMappedNote(this.application)
/** Register with 003 version */
await Factory.registerOldUser({
application: this.application,
email: this.email,
password: this.password,
version: oldVersion,
})
await Factory.setOldVersionPasscode({
application: this.application,
passcode: this.passcode,
version: oldVersion,
})
expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal(
oldVersion,
)
expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(oldVersion)
expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(oldVersion)
this.application.setLaunchCallback({
receiveChallenge: this.receiveChallenge,
})
const result = await this.application.upgradeProtocolVersion()
expect(result).to.deep.equal({ success: true })
const wrappedRootKey = await this.application.protocolService.rootKeyEncryption.getWrappedRootKey()
const payload = new EncryptedPayload(wrappedRootKey)
expect(payload.version).to.equal(newVersion)
expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal(
newVersion,
)
expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(newVersion)
expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(newVersion)
/**
* Immediately logging out ensures we don't rely on subsequent
* sync events to complete the upgrade
*/
this.application = await Factory.signOutApplicationAndReturnNew(this.application)
await this.application.signIn(this.email, this.password, undefined, undefined, undefined, true)
expect(this.application.itemManager.getDisplayableNotes().length).to.equal(1)
expect(this.application.payloadManager.invalidPayloads).to.be.empty
}).timeout(15000)
it('upgrading from 003 to 004 with passcode only then reiniting app should create valid state', async function () {
/**
* There was an issue where having the old app set up with passcode,
* then refreshing with new app, performing upgrade, then refreshing the app
* resulted in note data being errored.
*/
const oldVersion = ProtocolVersion.V003
await Factory.setOldVersionPasscode({
application: this.application,
passcode: this.passcode,
version: oldVersion,
})
await Factory.createSyncedNote(this.application)
this.application.setLaunchCallback({
receiveChallenge: this.receiveChallenge,
})
const identifier = this.application.identifier
/** Recreate the app once */
const appFirst = Factory.createApplicationWithFakeCrypto(identifier)
await appFirst.prepareForLaunch({
receiveChallenge: (challenge) => {
this.receiveChallengeWithApp(appFirst, challenge)
},
})
await appFirst.launch(true)
const result = await appFirst.upgradeProtocolVersion()
expect(result).to.deep.equal({ success: true })
expect(appFirst.payloadManager.invalidPayloads).to.be.empty
await Factory.safeDeinit(appFirst)
/** Recreate the once more */
const appSecond = Factory.createApplicationWithFakeCrypto(identifier)
await appSecond.prepareForLaunch({
receiveChallenge: (challenge) => {
this.receiveChallengeWithApp(appSecond, challenge)
},
})
await appSecond.launch(true)
expect(appSecond.payloadManager.invalidPayloads).to.be.empty
await Factory.safeDeinit(appSecond)
}).timeout(15000)
it('protocol version should be upgraded on password change', async function () {
/** Delete default items key that is created on launch */
const itemsKey = await this.application.protocolService.getSureDefaultItemsKey()
await this.application.itemManager.setItemToBeDeleted(itemsKey)
expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(0)
Factory.createMappedNote(this.application)
/** Register with 003 version */
await Factory.registerOldUser({
application: this.application,
email: this.email,
password: this.password,
version: ProtocolVersion.V003,
})
expect(this.application.itemManager.getDisplayableItemsKeys().length).to.equal(1)
expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(ProtocolVersion.V003)
expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(ProtocolVersion.V003)
/** Ensure note is encrypted with 003 */
const notePayloads = await Factory.getStoragePayloadsOfType(this.application, ContentType.Note)
expect(notePayloads.length).to.equal(1)
expect(notePayloads[0].version).to.equal(ProtocolVersion.V003)
const { error } = await this.application.changePassword(this.password, 'foobarfoo')
expect(error).to.not.exist
const latestVersion = this.application.protocolService.getLatestVersion()
expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(latestVersion)
expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(latestVersion)
const defaultItemsKey = await this.application.protocolService.getSureDefaultItemsKey()
expect(defaultItemsKey.keyVersion).to.equal(latestVersion)
/** After change, note should now be encrypted with latest protocol version */
const note = this.application.itemManager.getDisplayableNotes()[0]
await Factory.markDirtyAndSyncItem(this.application, note)
const refreshedNotePayloads = await Factory.getStoragePayloadsOfType(this.application, ContentType.Note)
const refreshedNotePayload = refreshedNotePayloads[0]
expect(refreshedNotePayload.version).to.equal(latestVersion)
}).timeout(5000)
describe('upgrade failure', function () {
this.timeout(30000)
const oldVersion = ProtocolVersion.V003
beforeEach(async function () {
await Factory.createMappedNote(this.application)
/** Register with 003 version */
await Factory.registerOldUser({
application: this.application,
email: this.email,
password: this.password,
version: oldVersion,
})
await Factory.setOldVersionPasscode({
application: this.application,
passcode: this.passcode,
version: oldVersion,
})
})
afterEach(function () {
sinon.restore()
})
it('rolls back the local protocol upgrade if syncing fails', async function () {
sinon.replace(this.application.syncService, 'sync', sinon.fake())
this.application.setLaunchCallback({
receiveChallenge: this.receiveChallenge,
})
expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal(
oldVersion,
)
const errors = await this.application.upgradeProtocolVersion()
expect(errors).to.not.be.empty
/** Ensure we're still on 003 */
expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal(
oldVersion,
)
expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(oldVersion)
expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(oldVersion)
expect((await this.application.protocolService.getSureDefaultItemsKey()).keyVersion).to.equal(oldVersion)
})
it('rolls back the local protocol upgrade if the server responds with an error', async function () {
sinon.replace(this.application.sessionManager, 'changeCredentials', () => [Error()])
this.application.setLaunchCallback({
receiveChallenge: this.receiveChallenge,
})
expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal(
oldVersion,
)
const errors = await this.application.upgradeProtocolVersion()
expect(errors).to.not.be.empty
/** Ensure we're still on 003 */
expect((await this.application.protocolService.rootKeyEncryption.getRootKeyWrapperKeyParams()).version).to.equal(
oldVersion,
)
expect((await this.application.protocolService.getRootKeyParams()).version).to.equal(oldVersion)
expect((await this.application.protocolService.getRootKey()).keyVersion).to.equal(oldVersion)
expect((await this.application.protocolService.getSureDefaultItemsKey()).keyVersion).to.equal(oldVersion)
})
})
})

View File

@@ -0,0 +1,256 @@
/* eslint-disable no-unused-expressions */
/* eslint-disable no-undef */
chai.use(chaiAsPromised)
const expect = chai.expect
describe('utils', () => {
it('findInArray', async () => {
expect(findInArray).to.be.ok
const array = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]
expect(findInArray(array, 'id', 1)).to.be.ok
expect(findInArray(array, 'id', 'foo')).to.not.be.ok
})
it('isNullOrUndefined', () => {
expect(isNullOrUndefined(null)).to.equal(true)
expect(isNullOrUndefined(undefined)).to.equal(true)
expect(isNullOrUndefined(1)).to.equal(false)
expect(isNullOrUndefined('foo')).to.equal(false)
expect(isNullOrUndefined({})).to.equal(false)
expect(isNullOrUndefined([null])).to.equal(false)
})
it('isValidUrl', () => {
expect(isValidUrl('http://foo.com')).to.equal(true)
expect(isValidUrl('https://foo.com')).to.equal(true)
expect(isValidUrl('http://localhost:3000')).to.equal(true)
expect(isValidUrl('http://localhost:3000/foo/bar')).to.equal(true)
expect(isValidUrl('http://192.168.1:3000/foo/bar')).to.equal(true)
expect(isValidUrl('://foo.com')).to.equal(false)
expect(isValidUrl('{foo}/foo/com')).to.equal(false)
expect(isValidUrl('foo.com')).to.equal(false)
expect(isValidUrl('www.foo.com')).to.equal(false)
})
it('extendArray', () => {
const array = [1, 2, 3]
const original = array.slice()
const extended = [4, 5, 6]
extendArray(array, extended)
expect(array).to.eql(original.concat(extended))
})
it('arraysEqual', () => {
expect(arraysEqual([1, 2, 3], [3, 2, 1])).to.equal(true)
expect(arraysEqual([2, 3], [3, 2, 1])).to.equal(false)
expect(arraysEqual([1, 2], [1, 2, 2])).to.equal(false)
expect(arraysEqual([1, 2, 3], [2, 3, 1])).to.equal(true)
expect(arraysEqual([1], [3])).to.equal(false)
})
it('top level compare', () => {
const left = { a: 1, b: 2 }
const right = { a: 1, b: 2 }
const middle = { a: 2, b: 1 }
expect(topLevelCompare(left, right)).to.equal(true)
expect(topLevelCompare(right, left)).to.equal(true)
expect(topLevelCompare(left, middle)).to.equal(false)
expect(topLevelCompare(middle, right)).to.equal(false)
})
it('jsonParseEmbeddedKeys', () => {
const object = {
a: { foo: 'bar' },
b: JSON.stringify({ foo: 'bar' }),
}
const parsed = jsonParseEmbeddedKeys(object)
expect(typeof parsed.a).to.equal('object')
expect(typeof parsed.b).to.equal('object')
})
it('omitUndefined', () => {
const object = {
foo: '123',
bar: undefined,
}
const omitted = omitUndefinedCopy(object)
expect(Object.keys(omitted).includes('bar')).to.equal(false)
})
it('dateSorted', () => {
const objects = [{ date: new Date(10) }, { date: new Date(5) }, { date: new Date(7) }]
/** ascending */
const ascending = dateSorted(objects, 'date', true)
expect(ascending[0].date.getTime()).to.equal(5)
expect(ascending[1].date.getTime()).to.equal(7)
expect(ascending[2].date.getTime()).to.equal(10)
/** descending */
const descending = dateSorted(objects, 'date', false)
expect(descending[0].date.getTime()).to.equal(10)
expect(descending[1].date.getTime()).to.equal(7)
expect(descending[2].date.getTime()).to.equal(5)
})
describe('subtractFromArray', () => {
it('Removes all items appearing in the array', () => {
const array = [1, 2, 3, 4, 5]
subtractFromArray(array, [1, 3, 5])
expect(array).to.eql([2, 4])
})
it('Ignores items not appearing in the array', () => {
const array = [1, 2, 3, 4, 5]
subtractFromArray(array, [0, 1, 3, 5])
expect(array).to.eql([2, 4])
})
})
describe('removeFromArray', () => {
it('Removes the first item appearing in the array', () => {
const array = [1, 1, 2, 3]
removeFromArray(array, 1)
expect(array).to.eql([1, 2, 3])
removeFromArray(array, 2)
expect(array).to.eql([1, 3])
})
it('Ignores items not appearing in the array', () => {
const array = [1, 2, 3]
removeFromArray(array, 0)
expect(array).to.eql([1, 2, 3])
removeFromArray(array, {})
})
})
it('removeFromIndex', () => {
const array = [1, 2, 3]
removeFromIndex(array, 1)
expect(array).to.eql([1, 3])
})
it('arrayByDifference', () => {
const array = [1, 2, 3, 4]
const array2 = [2, 3]
const result = arrayByDifference(array, array2)
expect(result).to.eql([1, 4])
})
it('uniqCombineObjArrays', () => {
const arrayA = [{ a: 'a', b: 'a' }]
const arrayB = [
{ a: 'a', b: 'a' },
{ a: '2', b: '2' },
]
const result = uniqCombineObjArrays(arrayA, arrayB, ['a', 'b'])
expect(result.length).to.equal(2)
})
it('uniqueArrayByKey', () => {
const arrayA = [{ uuid: 1 }, { uuid: 2 }]
const arrayB = [{ uuid: 1 }, { uuid: 2 }, { uuid: 1 }, { uuid: 2 }]
const result = uniqueArrayByKey(arrayA.concat(arrayB), ['uuid'])
expect(result.length).to.equal(2)
})
it('filterFromArray function predicate', () => {
const array = [{ uuid: 1 }, { uuid: 2 }, { uuid: 3 }]
filterFromArray(array, (o) => o.uuid === 1)
expect(array.length).to.equal(2)
})
it('lodash merge should behave as expected', () => {
const a = {
content: {
references: [{ a: 'a' }],
},
}
const b = {
content: {
references: [],
},
}
// merging a with b should replace total content
deepMerge(a, b)
expect(a.content.references).to.eql([])
})
it('truncates hex string', () => {
const hex256 = 'f0e4c2f76c58916ec258f246851bea091d14d4247a2fc3e18694461b1816e13b'
const desiredBits = 128
const expectedLength = 32
const result = truncateHexString(hex256, desiredBits)
expect(result.length).to.equal(expectedLength)
})
it('convertTimestampToMilliseconds', () => {
expect(convertTimestampToMilliseconds(1633636950)).to.equal(1633636950000)
expect(convertTimestampToMilliseconds(1633636950123)).to.equal(1633636950123)
expect(convertTimestampToMilliseconds(1633636950123456)).to.equal(1633636950123)
})
describe('isSameDay', () => {
it('returns true if two dates are on the same day', () => {
const dateA = new Date(2021, 1, 16, 16, 30, 0)
const dateB = new Date(2021, 1, 16, 17, 30, 0)
const result = isSameDay(dateA, dateB)
expect(result).to.equal(true)
})
it('returns false if two dates are not on the same day', () => {
const dateA = new Date(2021, 1, 16, 16, 30, 0)
const dateB = new Date(2021, 1, 17, 17, 30, 0)
const result = isSameDay(dateA, dateB)
expect(result).to.equal(false)
})
})
describe('naturalSort', () => {
let items
beforeEach(() => {
items = [
{
someProperty: 'a',
},
{
someProperty: 'b',
},
{
someProperty: '2',
},
{
someProperty: 'A',
},
{
someProperty: '1',
},
]
})
it('sorts elements in natural order in ascending direction by default', () => {
const result = naturalSort(items, 'someProperty')
expect(result).lengthOf(items.length)
expect(result[0]).to.equal(items[4])
expect(result[1]).to.equal(items[2])
expect(result[2]).to.equal(items[0])
expect(result[3]).to.equal(items[3])
expect(result[4]).to.equal(items[1])
})
it('sorts elements in natural order in descending direction', () => {
const result = naturalSort(items, 'someProperty', 'desc')
expect(result).lengthOf(items.length)
expect(result[0]).to.equal(items[1])
expect(result[1]).to.equal(items[3])
expect(result[2]).to.equal(items[0])
expect(result[3]).to.equal(items[2])
expect(result[4]).to.equal(items[4])
})
})
})

View File

@@ -0,0 +1,539 @@
(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.chaiAsPromised = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
"use strict";
/* eslint-disable no-invalid-this */
let checkError = require("check-error");
module.exports = (chai, utils) => {
const Assertion = chai.Assertion;
const assert = chai.assert;
const proxify = utils.proxify;
// If we are using a version of Chai that has checkError on it,
// we want to use that version to be consistent. Otherwise, we use
// what was passed to the factory.
if (utils.checkError) {
checkError = utils.checkError;
}
function isLegacyJQueryPromise(thenable) {
// jQuery promises are Promises/A+-compatible since 3.0.0. jQuery 3.0.0 is also the first version
// to define the catch method.
return typeof thenable.catch !== "function" &&
typeof thenable.always === "function" &&
typeof thenable.done === "function" &&
typeof thenable.fail === "function" &&
typeof thenable.pipe === "function" &&
typeof thenable.progress === "function" &&
typeof thenable.state === "function";
}
function assertIsAboutPromise(assertion) {
if (typeof assertion._obj.then !== "function") {
throw new TypeError(utils.inspect(assertion._obj) + " is not a thenable.");
}
if (isLegacyJQueryPromise(assertion._obj)) {
throw new TypeError("Chai as Promised is incompatible with thenables of jQuery<3.0.0, sorry! Please " +
"upgrade jQuery or use another Promises/A+ compatible library (see " +
"http://promisesaplus.com/).");
}
}
function proxifyIfSupported(assertion) {
return proxify === undefined ? assertion : proxify(assertion);
}
function method(name, asserter) {
utils.addMethod(Assertion.prototype, name, function () {
assertIsAboutPromise(this);
return asserter.apply(this, arguments);
});
}
function property(name, asserter) {
utils.addProperty(Assertion.prototype, name, function () {
assertIsAboutPromise(this);
return proxifyIfSupported(asserter.apply(this, arguments));
});
}
function doNotify(promise, done) {
promise.then(() => done(), done);
}
// These are for clarity and to bypass Chai refusing to allow `undefined` as actual when used with `assert`.
function assertIfNegated(assertion, message, extra) {
assertion.assert(true, null, message, extra.expected, extra.actual);
}
function assertIfNotNegated(assertion, message, extra) {
assertion.assert(false, message, null, extra.expected, extra.actual);
}
function getBasePromise(assertion) {
// We need to chain subsequent asserters on top of ones in the chain already (consider
// `eventually.have.property("foo").that.equals("bar")`), only running them after the existing ones pass.
// So the first base-promise is `assertion._obj`, but after that we use the assertions themselves, i.e.
// previously derived promises, to chain off of.
return typeof assertion.then === "function" ? assertion : assertion._obj;
}
function getReasonName(reason) {
return reason instanceof Error ? reason.toString() : checkError.getConstructorName(reason);
}
// Grab these first, before we modify `Assertion.prototype`.
const propertyNames = Object.getOwnPropertyNames(Assertion.prototype);
const propertyDescs = {};
for (const name of propertyNames) {
propertyDescs[name] = Object.getOwnPropertyDescriptor(Assertion.prototype, name);
}
property("fulfilled", function () {
const derivedPromise = getBasePromise(this).then(
value => {
assertIfNegated(this,
"expected promise not to be fulfilled but it was fulfilled with #{act}",
{ actual: value });
return value;
},
reason => {
assertIfNotNegated(this,
"expected promise to be fulfilled but it was rejected with #{act}",
{ actual: getReasonName(reason) });
return reason;
}
);
module.exports.transferPromiseness(this, derivedPromise);
return this;
});
property("rejected", function () {
const derivedPromise = getBasePromise(this).then(
value => {
assertIfNotNegated(this,
"expected promise to be rejected but it was fulfilled with #{act}",
{ actual: value });
return value;
},
reason => {
assertIfNegated(this,
"expected promise not to be rejected but it was rejected with #{act}",
{ actual: getReasonName(reason) });
// Return the reason, transforming this into a fulfillment, to allow further assertions, e.g.
// `promise.should.be.rejected.and.eventually.equal("reason")`.
return reason;
}
);
module.exports.transferPromiseness(this, derivedPromise);
return this;
});
method("rejectedWith", function (errorLike, errMsgMatcher, message) {
let errorLikeName = null;
const negate = utils.flag(this, "negate") || false;
// rejectedWith with that is called without arguments is
// the same as a plain ".rejected" use.
if (errorLike === undefined && errMsgMatcher === undefined &&
message === undefined) {
/* eslint-disable no-unused-expressions */
return this.rejected;
/* eslint-enable no-unused-expressions */
}
if (message !== undefined) {
utils.flag(this, "message", message);
}
if (errorLike instanceof RegExp || typeof errorLike === "string") {
errMsgMatcher = errorLike;
errorLike = null;
} else if (errorLike && errorLike instanceof Error) {
errorLikeName = errorLike.toString();
} else if (typeof errorLike === "function") {
errorLikeName = checkError.getConstructorName(errorLike);
} else {
errorLike = null;
}
const everyArgIsDefined = Boolean(errorLike && errMsgMatcher);
let matcherRelation = "including";
if (errMsgMatcher instanceof RegExp) {
matcherRelation = "matching";
}
const derivedPromise = getBasePromise(this).then(
value => {
let assertionMessage = null;
let expected = null;
if (errorLike) {
assertionMessage = "expected promise to be rejected with #{exp} but it was fulfilled with #{act}";
expected = errorLikeName;
} else if (errMsgMatcher) {
assertionMessage = `expected promise to be rejected with an error ${matcherRelation} #{exp} but ` +
`it was fulfilled with #{act}`;
expected = errMsgMatcher;
}
assertIfNotNegated(this, assertionMessage, { expected, actual: value });
return value;
},
reason => {
const errorLikeCompatible = errorLike && (errorLike instanceof Error ?
checkError.compatibleInstance(reason, errorLike) :
checkError.compatibleConstructor(reason, errorLike));
const errMsgMatcherCompatible = errMsgMatcher && checkError.compatibleMessage(reason, errMsgMatcher);
const reasonName = getReasonName(reason);
if (negate && everyArgIsDefined) {
if (errorLikeCompatible && errMsgMatcherCompatible) {
this.assert(true,
null,
"expected promise not to be rejected with #{exp} but it was rejected " +
"with #{act}",
errorLikeName,
reasonName);
}
} else {
if (errorLike) {
this.assert(errorLikeCompatible,
"expected promise to be rejected with #{exp} but it was rejected with #{act}",
"expected promise not to be rejected with #{exp} but it was rejected " +
"with #{act}",
errorLikeName,
reasonName);
}
if (errMsgMatcher) {
this.assert(errMsgMatcherCompatible,
`expected promise to be rejected with an error ${matcherRelation} #{exp} but got ` +
`#{act}`,
`expected promise not to be rejected with an error ${matcherRelation} #{exp}`,
errMsgMatcher,
checkError.getMessage(reason));
}
}
return reason;
}
);
module.exports.transferPromiseness(this, derivedPromise);
return this;
});
property("eventually", function () {
utils.flag(this, "eventually", true);
return this;
});
method("notify", function (done) {
doNotify(getBasePromise(this), done);
return this;
});
method("become", function (value, message) {
return this.eventually.deep.equal(value, message);
});
// ### `eventually`
// We need to be careful not to trigger any getters, thus `Object.getOwnPropertyDescriptor` usage.
const methodNames = propertyNames.filter(name => {
return name !== "assert" && typeof propertyDescs[name].value === "function";
});
methodNames.forEach(methodName => {
Assertion.overwriteMethod(methodName, originalMethod => function () {
return doAsserterAsyncAndAddThen(originalMethod, this, arguments);
});
});
const getterNames = propertyNames.filter(name => {
return name !== "_obj" && typeof propertyDescs[name].get === "function";
});
getterNames.forEach(getterName => {
// Chainable methods are things like `an`, which can work both for `.should.be.an.instanceOf` and as
// `should.be.an("object")`. We need to handle those specially.
const isChainableMethod = Assertion.prototype.__methods.hasOwnProperty(getterName);
if (isChainableMethod) {
Assertion.overwriteChainableMethod(
getterName,
originalMethod => function () {
return doAsserterAsyncAndAddThen(originalMethod, this, arguments);
},
originalGetter => function () {
return doAsserterAsyncAndAddThen(originalGetter, this);
}
);
} else {
Assertion.overwriteProperty(getterName, originalGetter => function () {
return proxifyIfSupported(doAsserterAsyncAndAddThen(originalGetter, this));
});
}
});
function doAsserterAsyncAndAddThen(asserter, assertion, args) {
// Since we're intercepting all methods/properties, we need to just pass through if they don't want
// `eventually`, or if we've already fulfilled the promise (see below).
if (!utils.flag(assertion, "eventually")) {
asserter.apply(assertion, args);
return assertion;
}
const derivedPromise = getBasePromise(assertion).then(value => {
// Set up the environment for the asserter to actually run: `_obj` should be the fulfillment value, and
// now that we have the value, we're no longer in "eventually" mode, so we won't run any of this code,
// just the base Chai code that we get to via the short-circuit above.
assertion._obj = value;
utils.flag(assertion, "eventually", false);
return args ? module.exports.transformAsserterArgs(args) : args;
}).then(newArgs => {
asserter.apply(assertion, newArgs);
// Because asserters, for example `property`, can change the value of `_obj` (i.e. change the "object"
// flag), we need to communicate this value change to subsequent chained asserters. Since we build a
// promise chain paralleling the asserter chain, we can use it to communicate such changes.
return assertion._obj;
});
module.exports.transferPromiseness(assertion, derivedPromise);
return assertion;
}
// ### Now use the `Assertion` framework to build an `assert` interface.
const originalAssertMethods = Object.getOwnPropertyNames(assert).filter(propName => {
return typeof assert[propName] === "function";
});
assert.isFulfilled = (promise, message) => (new Assertion(promise, message)).to.be.fulfilled;
assert.isRejected = (promise, errorLike, errMsgMatcher, message) => {
const assertion = new Assertion(promise, message);
return assertion.to.be.rejectedWith(errorLike, errMsgMatcher, message);
};
assert.becomes = (promise, value, message) => assert.eventually.deepEqual(promise, value, message);
assert.doesNotBecome = (promise, value, message) => assert.eventually.notDeepEqual(promise, value, message);
assert.eventually = {};
originalAssertMethods.forEach(assertMethodName => {
assert.eventually[assertMethodName] = function (promise) {
const otherArgs = Array.prototype.slice.call(arguments, 1);
let customRejectionHandler;
const message = arguments[assert[assertMethodName].length - 1];
if (typeof message === "string") {
customRejectionHandler = reason => {
throw new chai.AssertionError(`${message}\n\nOriginal reason: ${utils.inspect(reason)}`);
};
}
const returnedPromise = promise.then(
fulfillmentValue => assert[assertMethodName].apply(assert, [fulfillmentValue].concat(otherArgs)),
customRejectionHandler
);
returnedPromise.notify = done => {
doNotify(returnedPromise, done);
};
return returnedPromise;
};
});
};
module.exports.transferPromiseness = (assertion, promise) => {
assertion.then = promise.then.bind(promise);
};
module.exports.transformAsserterArgs = values => values;
},{"check-error":2}],2:[function(require,module,exports){
'use strict';
/* !
* Chai - checkError utility
* Copyright(c) 2012-2016 Jake Luer <jake@alogicalparadox.com>
* MIT Licensed
*/
/**
* ### .checkError
*
* Checks that an error conforms to a given set of criteria and/or retrieves information about it.
*
* @api public
*/
/**
* ### .compatibleInstance(thrown, errorLike)
*
* Checks if two instances are compatible (strict equal).
* Returns false if errorLike is not an instance of Error, because instances
* can only be compatible if they're both error instances.
*
* @name compatibleInstance
* @param {Error} thrown error
* @param {Error|ErrorConstructor} errorLike object to compare against
* @namespace Utils
* @api public
*/
function compatibleInstance(thrown, errorLike) {
return errorLike instanceof Error && thrown === errorLike;
}
/**
* ### .compatibleConstructor(thrown, errorLike)
*
* Checks if two constructors are compatible.
* This function can receive either an error constructor or
* an error instance as the `errorLike` argument.
* Constructors are compatible if they're the same or if one is
* an instance of another.
*
* @name compatibleConstructor
* @param {Error} thrown error
* @param {Error|ErrorConstructor} errorLike object to compare against
* @namespace Utils
* @api public
*/
function compatibleConstructor(thrown, errorLike) {
if (errorLike instanceof Error) {
// If `errorLike` is an instance of any error we compare their constructors
return thrown.constructor === errorLike.constructor || thrown instanceof errorLike.constructor;
} else if (errorLike.prototype instanceof Error || errorLike === Error) {
// If `errorLike` is a constructor that inherits from Error, we compare `thrown` to `errorLike` directly
return thrown.constructor === errorLike || thrown instanceof errorLike;
}
return false;
}
/**
* ### .compatibleMessage(thrown, errMatcher)
*
* Checks if an error's message is compatible with a matcher (String or RegExp).
* If the message contains the String or passes the RegExp test,
* it is considered compatible.
*
* @name compatibleMessage
* @param {Error} thrown error
* @param {String|RegExp} errMatcher to look for into the message
* @namespace Utils
* @api public
*/
function compatibleMessage(thrown, errMatcher) {
var comparisonString = typeof thrown === 'string' ? thrown : thrown.message;
if (errMatcher instanceof RegExp) {
return errMatcher.test(comparisonString);
} else if (typeof errMatcher === 'string') {
return comparisonString.indexOf(errMatcher) !== -1; // eslint-disable-line no-magic-numbers
}
return false;
}
/**
* ### .getFunctionName(constructorFn)
*
* Returns the name of a function.
* This also includes a polyfill function if `constructorFn.name` is not defined.
*
* @name getFunctionName
* @param {Function} constructorFn
* @namespace Utils
* @api private
*/
var functionNameMatch = /\s*function(?:\s|\s*\/\*[^(?:*\/)]+\*\/\s*)*([^\(\/]+)/;
function getFunctionName(constructorFn) {
var name = '';
if (typeof constructorFn.name === 'undefined') {
// Here we run a polyfill if constructorFn.name is not defined
var match = String(constructorFn).match(functionNameMatch);
if (match) {
name = match[1];
}
} else {
name = constructorFn.name;
}
return name;
}
/**
* ### .getConstructorName(errorLike)
*
* Gets the constructor name for an Error instance or constructor itself.
*
* @name getConstructorName
* @param {Error|ErrorConstructor} errorLike
* @namespace Utils
* @api public
*/
function getConstructorName(errorLike) {
var constructorName = errorLike;
if (errorLike instanceof Error) {
constructorName = getFunctionName(errorLike.constructor);
} else if (typeof errorLike === 'function') {
// If `err` is not an instance of Error it is an error constructor itself or another function.
// If we've got a common function we get its name, otherwise we may need to create a new instance
// of the error just in case it's a poorly-constructed error. Please see chaijs/chai/issues/45 to know more.
constructorName = getFunctionName(errorLike).trim() ||
getFunctionName(new errorLike()); // eslint-disable-line new-cap
}
return constructorName;
}
/**
* ### .getMessage(errorLike)
*
* Gets the error message from an error.
* If `err` is a String itself, we return it.
* If the error has no message, we return an empty string.
*
* @name getMessage
* @param {Error|String} errorLike
* @namespace Utils
* @api public
*/
function getMessage(errorLike) {
var msg = '';
if (errorLike && errorLike.message) {
msg = errorLike.message;
} else if (typeof errorLike === 'string') {
msg = errorLike;
}
return msg;
}
module.exports = {
compatibleInstance: compatibleInstance,
compatibleConstructor: compatibleConstructor,
compatibleMessage: compatibleMessage,
getMessage: getMessage,
getConstructorName: getConstructorName,
};
},{}]},{},[1])(1)
});

View File

@@ -0,0 +1,25 @@
chai.use(chaiAsPromised)
const expect = chai.expect
import * as Factory from './lib/factory.js'
describe('private workspaces', () => {
it('generates identifier', async () => {
const userphrase = 'myworkspaceuserphrase'
const name = 'myworkspacename'
const result = await ComputePrivateWorkspaceIdentifier(new SNWebCrypto(), userphrase, name)
expect(result).to.equal('5155c13a44f333790f6564fbcee0c35a16d26a8359dd77d67d8ecc6ad5d399bb')
})
it('application result matches direct function call', async () => {
const userphrase = 'myworkspaceuserphrase'
const name = 'myworkspacename'
const application = (await Factory.createAppContextWithRealCrypto()).application
const appResult = await application.computePrivateWorkspaceIdentifier(userphrase, name)
const directResult = await ComputePrivateWorkspaceIdentifier(new SNWebCrypto(), userphrase, name)
expect(appResult).to.equal(directResult)
})
})