feat(snjs): add revisions api v2 (#2154)
* feat(snjs): add revisions api v2 * fix(snjs): reference listing and getting revisions in specs * fix(snjs): revisions specs * fix(web): usage of revision metadata * fix(snjs): add specs for decryption revision * fix(snjs): issue with building mocked specs * fix(snjs): adjust revision creation delay
This commit is contained in:
158
packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.spec.ts
Normal file
158
packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.spec.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { EncryptedPayloadInterface, HistoryEntry } from '@standardnotes/models'
|
||||
import { EncryptionProviderInterface } from '@standardnotes/encryption'
|
||||
import { RevisionClientInterface } from '@standardnotes/services'
|
||||
jest.mock('@standardnotes/models', () => {
|
||||
const original = jest.requireActual('@standardnotes/models')
|
||||
|
||||
return {
|
||||
...original,
|
||||
isRemotePayloadAllowed: jest.fn(),
|
||||
}
|
||||
})
|
||||
const isRemotePayloadAllowed = require('@standardnotes/models').isRemotePayloadAllowed
|
||||
|
||||
import { Revision } from '../../Revision/Revision'
|
||||
|
||||
import { GetRevision } from './GetRevision'
|
||||
|
||||
describe('GetRevision', () => {
|
||||
let revisionManager: RevisionClientInterface
|
||||
let protocolService: EncryptionProviderInterface
|
||||
|
||||
const createUseCase = () => new GetRevision(revisionManager, protocolService)
|
||||
|
||||
beforeEach(() => {
|
||||
revisionManager = {} as jest.Mocked<RevisionClientInterface>
|
||||
revisionManager.getRevision = jest.fn().mockReturnValue({
|
||||
uuid: '00000000-0000-0000-0000-000000000000',
|
||||
item_uuid: '00000000-0000-0000-0000-000000000000',
|
||||
content: '004:foobar',
|
||||
content_type: 'Note',
|
||||
items_key_id: 'foobar',
|
||||
enc_item_key: 'foobar',
|
||||
auth_hash: 'foobar',
|
||||
created_at: '2021-01-01T00:00:00.000Z',
|
||||
updated_at: '2021-01-01T00:00:00.000Z'
|
||||
} as jest.Mocked<Revision>)
|
||||
|
||||
protocolService = {} as jest.Mocked<EncryptionProviderInterface>
|
||||
protocolService.getEmbeddedPayloadAuthenticatedData = jest.fn().mockReturnValue({ u: '00000000-0000-0000-0000-000000000000' })
|
||||
const encryptedPayload = {
|
||||
content: 'foobar',
|
||||
} as jest.Mocked<EncryptedPayloadInterface>
|
||||
encryptedPayload.copy = jest.fn().mockReturnValue(encryptedPayload)
|
||||
protocolService.decryptSplitSingle = jest.fn().mockReturnValue(encryptedPayload)
|
||||
|
||||
isRemotePayloadAllowed.mockImplementation(() => true)
|
||||
})
|
||||
|
||||
it('should get revision', async () => {
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({
|
||||
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(false)
|
||||
expect(result.getValue()).toBeInstanceOf(HistoryEntry)
|
||||
})
|
||||
|
||||
it('it should get a revision without uuid from embedded params', async () => {
|
||||
protocolService.getEmbeddedPayloadAuthenticatedData = jest.fn().mockReturnValue({ u: undefined })
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({
|
||||
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(false)
|
||||
expect(result.getValue()).toBeInstanceOf(HistoryEntry)
|
||||
})
|
||||
|
||||
it('it should get a revision without embedded params', async () => {
|
||||
protocolService.getEmbeddedPayloadAuthenticatedData = jest.fn().mockReturnValue(undefined)
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({
|
||||
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(false)
|
||||
expect(result.getValue()).toBeInstanceOf(HistoryEntry)
|
||||
})
|
||||
|
||||
it('should fail if item uuid is invalid', async () => {
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({
|
||||
itemUuid: 'invalid',
|
||||
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('Could not get revision: Given value is not a valid uuid: invalid')
|
||||
})
|
||||
|
||||
it('should fail if revision uuid is invalid', async () => {
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({
|
||||
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||
revisionUuid: 'invalid',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('Could not get revision: Given value is not a valid uuid: invalid')
|
||||
})
|
||||
|
||||
it('should fail if revision is not found', async () => {
|
||||
revisionManager.getRevision = jest.fn().mockReturnValue(null)
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({
|
||||
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('Could not get revision: Revision not found')
|
||||
})
|
||||
|
||||
it('should fail if there is an error in decrypting the revision', async () => {
|
||||
const encryptedPayload = {
|
||||
content: 'foobar',
|
||||
errorDecrypting: true,
|
||||
} as jest.Mocked<EncryptedPayloadInterface>
|
||||
encryptedPayload.copy = jest.fn().mockReturnValue(encryptedPayload)
|
||||
protocolService.decryptSplitSingle = jest.fn().mockReturnValue(encryptedPayload)
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({
|
||||
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
expect(result.getError()).toEqual('Could not decrypt revision.')
|
||||
})
|
||||
|
||||
it('should fail if remote payload is not allowed', async () => {
|
||||
isRemotePayloadAllowed.mockImplementation(() => false)
|
||||
|
||||
const useCase = createUseCase()
|
||||
|
||||
const result = await useCase.execute({
|
||||
itemUuid: '00000000-0000-0000-0000-000000000000',
|
||||
revisionUuid: '00000000-0000-0000-0000-000000000000',
|
||||
})
|
||||
|
||||
expect(result.isFailed()).toBe(true)
|
||||
})
|
||||
})
|
||||
80
packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.ts
Normal file
80
packages/snjs/lib/Domain/UseCase/GetRevision/GetRevision.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { RevisionClientInterface } from '@standardnotes/services'
|
||||
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
|
||||
import {
|
||||
EncryptedPayload,
|
||||
HistoryEntry,
|
||||
isErrorDecryptingPayload,
|
||||
isRemotePayloadAllowed,
|
||||
NoteContent,
|
||||
PayloadTimestampDefaults,
|
||||
} from '@standardnotes/models'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { EncryptionProviderInterface } from '@standardnotes/encryption'
|
||||
|
||||
import { GetRevisionDTO } from './GetRevisionDTO'
|
||||
|
||||
export class GetRevision implements UseCaseInterface<HistoryEntry> {
|
||||
constructor(private revisionManager: RevisionClientInterface, private protocolService: EncryptionProviderInterface) {}
|
||||
|
||||
async execute(dto: GetRevisionDTO): Promise<Result<HistoryEntry>> {
|
||||
const itemUuidOrError = Uuid.create(dto.itemUuid)
|
||||
if (itemUuidOrError.isFailed()) {
|
||||
return Result.fail(`Could not get revision: ${itemUuidOrError.getError()}`)
|
||||
}
|
||||
const itemUuid = itemUuidOrError.getValue()
|
||||
|
||||
const revisionUuidOrError = Uuid.create(dto.revisionUuid)
|
||||
if (revisionUuidOrError.isFailed()) {
|
||||
return Result.fail(`Could not get revision: ${revisionUuidOrError.getError()}`)
|
||||
}
|
||||
const revisionUuid = revisionUuidOrError.getValue()
|
||||
|
||||
const revision = await this.revisionManager.getRevision(itemUuid, revisionUuid)
|
||||
if (revision === null) {
|
||||
return Result.fail('Could not get revision: Revision not found')
|
||||
}
|
||||
|
||||
const serverPayload = new EncryptedPayload({
|
||||
...PayloadTimestampDefaults(),
|
||||
uuid: revision.uuid,
|
||||
content: revision.content as string,
|
||||
enc_item_key: revision.enc_item_key as string,
|
||||
items_key_id: revision.items_key_id as string,
|
||||
auth_hash: revision.auth_hash as string,
|
||||
content_type: revision.content_type as ContentType,
|
||||
updated_at: new Date(revision.updated_at),
|
||||
created_at: new Date(revision.created_at),
|
||||
waitingForKey: false,
|
||||
errorDecrypting: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* When an item is duplicated, its revisions also carry over to the newly created item.
|
||||
* However since the new item has a different UUID than the source item, we must decrypt
|
||||
* these olders revisions (which have not been mutated after copy) with the source item's
|
||||
* uuid.
|
||||
*/
|
||||
const embeddedParams = this.protocolService.getEmbeddedPayloadAuthenticatedData(serverPayload)
|
||||
const sourceItemUuid = embeddedParams?.u as string | undefined
|
||||
|
||||
const payload = serverPayload.copy({
|
||||
uuid: sourceItemUuid || revision.item_uuid,
|
||||
})
|
||||
|
||||
if (!isRemotePayloadAllowed(payload)) {
|
||||
return Result.fail(`Remote payload is disallowed: ${JSON.stringify(payload)}`)
|
||||
}
|
||||
|
||||
const encryptedPayload = new EncryptedPayload(payload)
|
||||
|
||||
const decryptedPayload = await this.protocolService.decryptSplitSingle<NoteContent>({
|
||||
usesItemsKeyWithKeyLookup: { items: [encryptedPayload] },
|
||||
})
|
||||
|
||||
if (isErrorDecryptingPayload(decryptedPayload)) {
|
||||
return Result.fail('Could not decrypt revision.')
|
||||
}
|
||||
|
||||
return Result.ok(new HistoryEntry(decryptedPayload))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface GetRevisionDTO {
|
||||
itemUuid: string
|
||||
revisionUuid: string
|
||||
}
|
||||
Reference in New Issue
Block a user