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:
Karol Sójko
2023-01-18 09:20:06 +01:00
committed by GitHub
parent 7d7815917b
commit 880a537774
52 changed files with 882 additions and 226 deletions

View File

@@ -0,0 +1,49 @@
import { RevisionClientInterface } from '@standardnotes/services'
import { DeleteRevision } from './DeleteRevision'
describe('DeleteRevision', () => {
let revisionManager: RevisionClientInterface
const createUseCase = () => new DeleteRevision(revisionManager)
beforeEach(() => {
revisionManager = {} as jest.Mocked<RevisionClientInterface>
revisionManager.deleteRevision = jest.fn()
})
it('should delete 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)
})
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 delete 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 delete revision: Given value is not a valid uuid: invalid')
})
})

View File

@@ -0,0 +1,26 @@
import { RevisionClientInterface } from '@standardnotes/services'
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { DeleteRevisionDTO } from './DeleteRevisionDTO'
export class DeleteRevision implements UseCaseInterface<void> {
constructor(private revisionManager: RevisionClientInterface) {}
async execute(dto: DeleteRevisionDTO): Promise<Result<void>> {
const itemUuidOrError = Uuid.create(dto.itemUuid)
if (itemUuidOrError.isFailed()) {
return Result.fail(`Could not delete revision: ${itemUuidOrError.getError()}`)
}
const itemUuid = itemUuidOrError.getValue()
const revisionUuidOrError = Uuid.create(dto.revisionUuid)
if (revisionUuidOrError.isFailed()) {
return Result.fail(`Could not delete revision: ${revisionUuidOrError.getError()}`)
}
const revisionUuid = revisionUuidOrError.getValue()
await this.revisionManager.deleteRevision(itemUuid, revisionUuid)
return Result.ok()
}
}

View File

@@ -0,0 +1,4 @@
export interface DeleteRevisionDTO {
itemUuid: string
revisionUuid: string
}

View 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)
})
})

View 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))
}
}

View File

@@ -0,0 +1,4 @@
export interface GetRevisionDTO {
itemUuid: string
revisionUuid: string
}

View File

@@ -0,0 +1,32 @@
import { RevisionClientInterface } from '@standardnotes/services'
import { ListRevisions } from './ListRevisions'
describe('ListRevisions', () => {
let revisionManager: RevisionClientInterface
const createUseCase = () => new ListRevisions(revisionManager)
beforeEach(() => {
revisionManager = {} as jest.Mocked<RevisionClientInterface>
revisionManager.listRevisions = jest.fn().mockReturnValue([])
})
it('should list revisions', async () => {
const useCase = createUseCase()
const result = await useCase.execute({ itemUuid: '00000000-0000-0000-0000-000000000000' })
expect(result.isFailed()).toBe(false)
expect(result.getValue()).toEqual([])
})
it('should fail if item uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({ itemUuid: 'invalid' })
expect(result.isFailed()).toBe(true)
expect(result.getError()).toEqual('Could not list item revisions: Given value is not a valid uuid: invalid')
})
})

View File

@@ -0,0 +1,22 @@
import { RevisionClientInterface } from '@standardnotes/services'
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { RevisionMetadata } from '../../Revision/RevisionMetadata'
import { ListRevisionsDTO } from './ListRevisionsDTO'
export class ListRevisions implements UseCaseInterface<Array<RevisionMetadata>> {
constructor(private revisionManager: RevisionClientInterface) {}
async execute(dto: ListRevisionsDTO): Promise<Result<RevisionMetadata[]>> {
const itemUuidOrError = Uuid.create(dto.itemUuid)
if (itemUuidOrError.isFailed()) {
return Result.fail(`Could not list item revisions: ${itemUuidOrError.getError()}`)
}
const itemUuid = itemUuidOrError.getValue()
const revisions = await this.revisionManager.listRevisions(itemUuid)
return Result.ok(revisions)
}
}

View File

@@ -0,0 +1,3 @@
export interface ListRevisionsDTO {
itemUuid: string
}

View File

@@ -1,5 +1,8 @@
import { HistoryEntry } from '@standardnotes/models'
import { UseCaseInterface } from '@standardnotes/domain-core'
import { RevisionMetadata } from '../Revision/RevisionMetadata'
export interface UseCaseContainerInterface {
get signInWithRecoveryCodes(): UseCaseInterface<void>
get getRecoveryCodes(): UseCaseInterface<string>
@@ -7,4 +10,7 @@ export interface UseCaseContainerInterface {
get listAuthenticators(): UseCaseInterface<Array<{ id: string; name: string }>>
get deleteAuthenticator(): UseCaseInterface<void>
get verifyAuthenticator(): UseCaseInterface<void>
get listRevisions(): UseCaseInterface<Array<RevisionMetadata>>
get getRevision(): UseCaseInterface<HistoryEntry>
get deleteRevision(): UseCaseInterface<void>
}