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:
@@ -5,6 +5,8 @@ import {
|
||||
AuthServer,
|
||||
HttpService,
|
||||
HttpServiceInterface,
|
||||
RevisionApiService,
|
||||
RevisionServer,
|
||||
SubscriptionApiService,
|
||||
SubscriptionApiServiceInterface,
|
||||
SubscriptionServer,
|
||||
@@ -71,6 +73,8 @@ import {
|
||||
AuthenticatorManager,
|
||||
AuthClientInterface,
|
||||
AuthManager,
|
||||
RevisionClientInterface,
|
||||
RevisionManager,
|
||||
} from '@standardnotes/services'
|
||||
import { BackupServiceInterface, FilesClientInterface } from '@standardnotes/files'
|
||||
import { ComputePrivateUsername } from '@standardnotes/encryption'
|
||||
@@ -80,6 +84,7 @@ import {
|
||||
DecryptedItemInterface,
|
||||
EncryptedItemInterface,
|
||||
Environment,
|
||||
HistoryEntry,
|
||||
ItemStream,
|
||||
Platform,
|
||||
} from '@standardnotes/models'
|
||||
@@ -100,6 +105,10 @@ import { AddAuthenticator } from '@Lib/Domain/UseCase/AddAuthenticator/AddAuthen
|
||||
import { ListAuthenticators } from '@Lib/Domain/UseCase/ListAuthenticators/ListAuthenticators'
|
||||
import { DeleteAuthenticator } from '@Lib/Domain/UseCase/DeleteAuthenticator/DeleteAuthenticator'
|
||||
import { VerifyAuthenticator } from '@Lib/Domain/UseCase/VerifyAuthenticator/VerifyAuthenticator'
|
||||
import { ListRevisions } from '@Lib/Domain/UseCase/ListRevisions/ListRevisions'
|
||||
import { GetRevision } from '@Lib/Domain/UseCase/GetRevision/GetRevision'
|
||||
import { DeleteRevision } from '@Lib/Domain/UseCase/DeleteRevision/DeleteRevision'
|
||||
import { RevisionMetadata } from '@Lib/Domain/Revision/RevisionMetadata'
|
||||
|
||||
/** How often to automatically sync, in milliseconds */
|
||||
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
|
||||
@@ -176,6 +185,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
private declare legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
|
||||
private declare authenticatorManager: AuthenticatorClientInterface
|
||||
private declare authManager: AuthClientInterface
|
||||
private declare revisionManager: RevisionClientInterface
|
||||
|
||||
private declare _signInWithRecoveryCodes: SignInWithRecoveryCodes
|
||||
private declare _getRecoveryCodes: GetRecoveryCodes
|
||||
@@ -183,6 +193,9 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
private declare _listAuthenticators: ListAuthenticators
|
||||
private declare _deleteAuthenticator: DeleteAuthenticator
|
||||
private declare _verifyAuthenticator: VerifyAuthenticator
|
||||
private declare _listRevisions: ListRevisions
|
||||
private declare _getRevision: GetRevision
|
||||
private declare _deleteRevision: DeleteRevision
|
||||
|
||||
private internalEventBus!: ExternalServices.InternalEventBusInterface
|
||||
|
||||
@@ -289,6 +302,18 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
return this._verifyAuthenticator
|
||||
}
|
||||
|
||||
get listRevisions(): UseCaseInterface<Array<RevisionMetadata>> {
|
||||
return this._listRevisions
|
||||
}
|
||||
|
||||
get getRevision(): UseCaseInterface<HistoryEntry> {
|
||||
return this._getRevision
|
||||
}
|
||||
|
||||
get deleteRevision(): UseCaseInterface<void> {
|
||||
return this._deleteRevision
|
||||
}
|
||||
|
||||
public get files(): FilesClientInterface {
|
||||
return this.fileService
|
||||
}
|
||||
@@ -1188,6 +1213,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.createActionsManager()
|
||||
this.createAuthenticatorManager()
|
||||
this.createAuthManager()
|
||||
this.createRevisionManager()
|
||||
|
||||
this.createUseCases()
|
||||
}
|
||||
@@ -1239,12 +1265,16 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
;(this.legacySessionStorageMapper as unknown) = undefined
|
||||
;(this.authenticatorManager as unknown) = undefined
|
||||
;(this.authManager as unknown) = undefined
|
||||
;(this.revisionManager as unknown) = undefined
|
||||
;(this._signInWithRecoveryCodes as unknown) = undefined
|
||||
;(this._getRecoveryCodes as unknown) = undefined
|
||||
;(this._addAuthenticator as unknown) = undefined
|
||||
;(this._listAuthenticators as unknown) = undefined
|
||||
;(this._deleteAuthenticator as unknown) = undefined
|
||||
;(this._verifyAuthenticator as unknown) = undefined
|
||||
;(this._listRevisions as unknown) = undefined
|
||||
;(this._getRevision as unknown) = undefined
|
||||
;(this._deleteRevision as unknown) = undefined
|
||||
|
||||
this.services = []
|
||||
}
|
||||
@@ -1683,8 +1713,6 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.historyManager = new InternalServices.SNHistoryManager(
|
||||
this.itemManager,
|
||||
this.diskStorageService,
|
||||
this.apiService,
|
||||
this.protocolService,
|
||||
this.deviceInterface,
|
||||
this.internalEventBus,
|
||||
)
|
||||
@@ -1790,6 +1818,14 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.authManager = new AuthManager(authApiService, this.internalEventBus)
|
||||
}
|
||||
|
||||
private createRevisionManager() {
|
||||
const revisionServer = new RevisionServer(this.httpService)
|
||||
|
||||
const revisionApiService = new RevisionApiService(revisionServer)
|
||||
|
||||
this.revisionManager = new RevisionManager(revisionApiService, this.internalEventBus)
|
||||
}
|
||||
|
||||
private createUseCases() {
|
||||
this._signInWithRecoveryCodes = new SignInWithRecoveryCodes(
|
||||
this.authManager,
|
||||
@@ -1815,5 +1851,11 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
|
||||
this.authenticatorManager,
|
||||
this.options.u2fAuthenticatorVerificationPromptFunction,
|
||||
)
|
||||
|
||||
this._listRevisions = new ListRevisions(this.revisionManager)
|
||||
|
||||
this._getRevision = new GetRevision(this.revisionManager, this.protocolService)
|
||||
|
||||
this._deleteRevision = new DeleteRevision(this.revisionManager)
|
||||
}
|
||||
}
|
||||
|
||||
11
packages/snjs/lib/Domain/Revision/Revision.ts
Normal file
11
packages/snjs/lib/Domain/Revision/Revision.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface Revision {
|
||||
uuid: string
|
||||
item_uuid: string
|
||||
content: string | null
|
||||
content_type: string
|
||||
items_key_id: string | null
|
||||
enc_item_key: string | null
|
||||
auth_hash: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
7
packages/snjs/lib/Domain/Revision/RevisionMetadata.ts
Normal file
7
packages/snjs/lib/Domain/Revision/RevisionMetadata.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface RevisionMetadata {
|
||||
uuid: string
|
||||
content_type: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
required_role: string
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface DeleteRevisionDTO {
|
||||
itemUuid: string
|
||||
revisionUuid: string
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface ListRevisionsDTO {
|
||||
itemUuid: string
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
2
packages/snjs/lib/Domain/index.ts
Normal file
2
packages/snjs/lib/Domain/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './Revision/Revision'
|
||||
export * from './Revision/RevisionMetadata'
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
API_MESSAGE_CHANGE_CREDENTIALS_IN_PROGRESS,
|
||||
API_MESSAGE_FAILED_ACCESS_PURCHASE,
|
||||
API_MESSAGE_FAILED_CREATE_FILE_TOKEN,
|
||||
API_MESSAGE_FAILED_DELETE_REVISION,
|
||||
API_MESSAGE_FAILED_GET_SETTINGS,
|
||||
API_MESSAGE_FAILED_LISTED_REGISTRATION,
|
||||
API_MESSAGE_FAILED_OFFLINE_ACTIVATION,
|
||||
@@ -488,7 +487,7 @@ export class SNApiService
|
||||
return preprocessingError
|
||||
}
|
||||
const url = joinPaths(this.host, <string>Paths.v1.session(sessionId))
|
||||
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
|
||||
const response: Responses.SessionListResponse | Responses.HttpResponse = await this.httpService
|
||||
.deleteAbsolute(url, { uuid: sessionId }, this.getSessionAccessToken())
|
||||
.catch((error: Responses.HttpResponse) => {
|
||||
const errorResponse = error as Responses.HttpResponse
|
||||
@@ -505,53 +504,6 @@ export class SNApiService
|
||||
return response
|
||||
}
|
||||
|
||||
async getItemRevisions(itemId: UuidString): Promise<Responses.RevisionListResponse | Responses.HttpResponse> {
|
||||
const preprocessingError = this.preprocessingError()
|
||||
if (preprocessingError) {
|
||||
return preprocessingError
|
||||
}
|
||||
const url = joinPaths(this.host, Paths.v1.itemRevisions(itemId))
|
||||
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
|
||||
.getAbsolute(url, undefined, this.getSessionAccessToken())
|
||||
.catch((errorResponse: Responses.HttpResponse) => {
|
||||
this.preprocessAuthenticatedErrorResponse(errorResponse)
|
||||
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
|
||||
return this.refreshSessionThenRetryRequest({
|
||||
verb: HttpVerb.Get,
|
||||
url,
|
||||
})
|
||||
}
|
||||
return this.errorResponseWithFallbackMessage(errorResponse, API_MESSAGE_GENERIC_SYNC_FAIL)
|
||||
})
|
||||
this.processResponse(response)
|
||||
return response
|
||||
}
|
||||
|
||||
async getRevision(
|
||||
entry: Responses.RevisionListEntry,
|
||||
itemId: UuidString,
|
||||
): Promise<Responses.SingleRevisionResponse | Responses.HttpResponse> {
|
||||
const preprocessingError = this.preprocessingError()
|
||||
if (preprocessingError) {
|
||||
return preprocessingError
|
||||
}
|
||||
const url = joinPaths(this.host, Paths.v1.itemRevision(itemId, entry.uuid))
|
||||
const response: Responses.SingleRevisionResponse | Responses.HttpResponse = await this.httpService
|
||||
.getAbsolute(url, undefined, this.getSessionAccessToken())
|
||||
.catch((errorResponse: Responses.HttpResponse) => {
|
||||
this.preprocessAuthenticatedErrorResponse(errorResponse)
|
||||
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
|
||||
return this.refreshSessionThenRetryRequest({
|
||||
verb: HttpVerb.Get,
|
||||
url,
|
||||
})
|
||||
}
|
||||
return this.errorResponseWithFallbackMessage(errorResponse, API_MESSAGE_GENERIC_SYNC_FAIL)
|
||||
})
|
||||
this.processResponse(response)
|
||||
return response
|
||||
}
|
||||
|
||||
async getUserFeatures(userUuid: UuidString): Promise<Responses.HttpResponse | Responses.UserFeaturesResponse> {
|
||||
const url = joinPaths(this.host, Paths.v1.userFeatures(userUuid))
|
||||
const response = await this.httpService
|
||||
@@ -652,20 +604,6 @@ export class SNApiService
|
||||
})
|
||||
}
|
||||
|
||||
async deleteRevision(
|
||||
itemUuid: UuidString,
|
||||
entry: Responses.RevisionListEntry,
|
||||
): Promise<Responses.MinimalHttpResponse> {
|
||||
const url = joinPaths(this.host, Paths.v1.itemRevision(itemUuid, entry.uuid))
|
||||
const response = await this.tokenRefreshableRequest({
|
||||
verb: HttpVerb.Delete,
|
||||
url,
|
||||
fallbackErrorMessage: API_MESSAGE_FAILED_DELETE_REVISION,
|
||||
authentication: this.getSessionAccessToken(),
|
||||
})
|
||||
return response
|
||||
}
|
||||
|
||||
public downloadFeatureUrl(url: string): Promise<Responses.HttpResponse> {
|
||||
return this.request({
|
||||
verb: HttpVerb.Get,
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { ContentType, Uuid } from '@standardnotes/common'
|
||||
import { isNullOrUndefined, removeFromArray } from '@standardnotes/utils'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { removeFromArray } from '@standardnotes/utils'
|
||||
import { ItemManager } from '@Lib/Services/Items/ItemManager'
|
||||
import { SNApiService } from '@Lib/Services/Api/ApiService'
|
||||
import { DiskStorageService } from '@Lib/Services/Storage/DiskStorageService'
|
||||
import { UuidString } from '../../Types/UuidString'
|
||||
import * as Models from '@standardnotes/models'
|
||||
import * as Responses from '@standardnotes/responses'
|
||||
import { isErrorDecryptingPayload, PayloadTimestampDefaults, SNNote } from '@standardnotes/models'
|
||||
import { AbstractService, EncryptionService, DeviceInterface, InternalEventBusInterface } from '@standardnotes/services'
|
||||
import { SNNote } from '@standardnotes/models'
|
||||
import { AbstractService, DeviceInterface, InternalEventBusInterface } from '@standardnotes/services'
|
||||
|
||||
/** The amount of revisions per item above which should call for an optimization. */
|
||||
const DefaultItemRevisionsThreshold = 20
|
||||
@@ -46,8 +44,6 @@ export class SNHistoryManager extends AbstractService {
|
||||
constructor(
|
||||
private itemManager: ItemManager,
|
||||
private storageService: DiskStorageService,
|
||||
private apiService: SNApiService,
|
||||
private protocolService: EncryptionService,
|
||||
public deviceInterface: DeviceInterface,
|
||||
protected override internalEventBus: InternalEventBusInterface,
|
||||
) {
|
||||
@@ -120,78 +116,6 @@ export class SNHistoryManager extends AbstractService {
|
||||
return Object.freeze(copy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a list of revisions from the server for an item. These revisions do not
|
||||
* include the item's content. Instead, each revision's content must be fetched
|
||||
* individually upon selection via `fetchRemoteRevision`.
|
||||
*/
|
||||
async remoteHistoryForItem(item: Models.SNNote): Promise<Responses.RevisionListEntry[] | undefined> {
|
||||
const response = await this.apiService.getItemRevisions(item.uuid)
|
||||
if (response.error || isNullOrUndefined(response.data)) {
|
||||
return undefined
|
||||
}
|
||||
return (response as Responses.RevisionListResponse).data
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands on a revision fetched via `remoteHistoryForItem` by getting a revision's
|
||||
* complete fields (including encrypted content).
|
||||
*/
|
||||
async fetchRemoteRevision(
|
||||
note: Models.SNNote,
|
||||
entry: Responses.RevisionListEntry,
|
||||
): Promise<Models.HistoryEntry | undefined> {
|
||||
const revisionResponse = await this.apiService.getRevision(entry, note.uuid)
|
||||
if (revisionResponse.error || isNullOrUndefined(revisionResponse.data)) {
|
||||
return undefined
|
||||
}
|
||||
const revision = (revisionResponse as Responses.SingleRevisionResponse).data
|
||||
|
||||
const serverPayload = new Models.EncryptedPayload({
|
||||
...PayloadTimestampDefaults(),
|
||||
...revision,
|
||||
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 Uuid | undefined
|
||||
|
||||
const payload = serverPayload.copy({
|
||||
uuid: sourceItemUuid || revision.item_uuid,
|
||||
})
|
||||
|
||||
if (!Models.isRemotePayloadAllowed(payload)) {
|
||||
console.error('Remote payload is disallowed', payload)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const encryptedPayload = new Models.EncryptedPayload(payload)
|
||||
|
||||
const decryptedPayload = await this.protocolService.decryptSplitSingle<Models.NoteContent>({
|
||||
usesItemsKeyWithKeyLookup: { items: [encryptedPayload] },
|
||||
})
|
||||
|
||||
if (isErrorDecryptingPayload(decryptedPayload)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return new Models.HistoryEntry(decryptedPayload)
|
||||
}
|
||||
|
||||
async deleteRemoteRevision(note: SNNote, entry: Responses.RevisionListEntry): Promise<Responses.MinimalHttpResponse> {
|
||||
const response = await this.apiService.deleteRevision(note.uuid, entry)
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up if there are too many revisions. Note itemRevisionThreshold
|
||||
* is the amount of revisions which above, call for an optimization. An
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './Application'
|
||||
export * from './ApplicationGroup'
|
||||
export * from './Client'
|
||||
export * from './Domain'
|
||||
export * from './Log'
|
||||
export * from './Migrations'
|
||||
export * from './Services'
|
||||
|
||||
@@ -282,21 +282,30 @@ describe('history manager', () => {
|
||||
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
|
||||
const itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: item.uuid })
|
||||
|
||||
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||
expect(itemHistoryOrError.getValue().length).to.equal(0)
|
||||
})
|
||||
|
||||
it('create basic history entries 2', async function () {
|
||||
const item = await Factory.createSyncedNote(this.application)
|
||||
let itemHistory = await this.historyManager.remoteHistoryForItem(item)
|
||||
await Factory.sleep(Factory.ServerRevisionCreationDelay)
|
||||
|
||||
let itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: item.uuid })
|
||||
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||
|
||||
let itemHistory = itemHistoryOrError.getValue()
|
||||
|
||||
/** 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)
|
||||
itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: item.uuid })
|
||||
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||
|
||||
itemHistory = itemHistoryOrError.getValue()
|
||||
expect(itemHistory.length).to.equal(1)
|
||||
|
||||
/** Sync with different contents, should not create a new entry */
|
||||
@@ -309,7 +318,10 @@ describe('history manager', () => {
|
||||
undefined,
|
||||
syncOptions,
|
||||
)
|
||||
itemHistory = await this.historyManager.remoteHistoryForItem(item)
|
||||
itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: item.uuid })
|
||||
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||
|
||||
itemHistory = itemHistoryOrError.getValue()
|
||||
expect(itemHistory.length).to.equal(1)
|
||||
})
|
||||
|
||||
@@ -328,11 +340,19 @@ describe('history manager', () => {
|
||||
undefined,
|
||||
syncOptions,
|
||||
)
|
||||
let itemHistory = await this.historyManager.remoteHistoryForItem(item)
|
||||
await Factory.sleep(Factory.ServerRevisionCreationDelay)
|
||||
|
||||
const itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: item.uuid })
|
||||
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||
|
||||
const itemHistory = itemHistoryOrError.getValue()
|
||||
expect(itemHistory.length).to.equal(2)
|
||||
|
||||
const oldestEntry = lastElement(itemHistory)
|
||||
let revisionFromServer = await this.historyManager.fetchRemoteRevision(item, oldestEntry)
|
||||
let revisionFromServerOrError = await this.application.getRevision.execute({ itemUuid: item.uuid, revisionUuid: oldestEntry.uuid })
|
||||
expect(revisionFromServerOrError.isFailed()).to.equal(false)
|
||||
|
||||
const revisionFromServer = revisionFromServerOrError.getValue()
|
||||
expect(revisionFromServer).to.be.ok
|
||||
|
||||
let payloadFromServer = revisionFromServer.payload
|
||||
@@ -349,9 +369,16 @@ describe('history manager', () => {
|
||||
await Factory.markDirtyAndSyncItem(this.application, note)
|
||||
const dupe = await this.application.itemManager.duplicateItem(note, true)
|
||||
await Factory.markDirtyAndSyncItem(this.application, dupe)
|
||||
await Factory.sleep(Factory.ServerRevisionCreationDelay)
|
||||
|
||||
const dupeHistory = await this.historyManager.remoteHistoryForItem(dupe)
|
||||
const dupeRevision = await this.historyManager.fetchRemoteRevision(dupe, dupeHistory[0])
|
||||
const dupeHistoryOrError = await this.application.listRevisions.execute({ itemUuid: dupe.uuid })
|
||||
expect(dupeHistoryOrError.isFailed()).to.equal(false)
|
||||
const dupeHistory = dupeHistoryOrError.getValue()
|
||||
|
||||
const dupeRevisionOrError = await this.application.getRevision.execute({ itemUuid: dupe.uuid, revisionUuid: dupeHistory[0].uuid })
|
||||
expect(dupeRevisionOrError.isFailed()).to.equal(false)
|
||||
|
||||
const dupeRevision = dupeRevisionOrError.getValue()
|
||||
expect(dupeRevision.payload.uuid).to.equal(dupe.uuid)
|
||||
})
|
||||
|
||||
@@ -376,8 +403,14 @@ describe('history manager', () => {
|
||||
await Factory.markDirtyAndSyncItem(this.application, dupe)
|
||||
|
||||
const expectedRevisions = 3
|
||||
const noteHistory = await this.historyManager.remoteHistoryForItem(note)
|
||||
const dupeHistory = await this.historyManager.remoteHistoryForItem(dupe)
|
||||
const noteHistoryOrError = await this.application.listRevisions.execute({ itemUuid: note.uuid })
|
||||
expect(noteHistoryOrError.isFailed()).to.equal(false)
|
||||
const noteHistory = noteHistoryOrError.getValue()
|
||||
|
||||
const dupeHistoryOrError = await this.application.listRevisions.execute({ itemUuid: dupe.uuid })
|
||||
expect(dupeHistoryOrError.isFailed()).to.equal(false)
|
||||
const dupeHistory = dupeHistoryOrError.getValue()
|
||||
|
||||
expect(noteHistory.length).to.equal(expectedRevisions)
|
||||
expect(dupeHistory.length).to.equal(expectedRevisions)
|
||||
})
|
||||
@@ -398,11 +431,17 @@ describe('history manager', () => {
|
||||
|
||||
const dupe = await this.application.itemManager.duplicateItem(note, true)
|
||||
await Factory.markDirtyAndSyncItem(this.application, dupe)
|
||||
const itemHistory = await this.historyManager.remoteHistoryForItem(dupe)
|
||||
const itemHistoryOrError = await this.application.listRevisions.execute({ itemUuid: dupe.uuid })
|
||||
expect(itemHistoryOrError.isFailed()).to.equal(false)
|
||||
|
||||
const itemHistory = itemHistoryOrError.getValue()
|
||||
expect(itemHistory.length).to.be.above(1)
|
||||
const oldestRevision = lastElement(itemHistory)
|
||||
|
||||
const fetched = await this.historyManager.fetchRemoteRevision(dupe, oldestRevision)
|
||||
const fetchedOrError = await this.application.getRevision.execute({ itemUuid: dupe.uuid, revisionUuid: oldestRevision.uuid })
|
||||
expect(fetchedOrError.isFailed()).to.equal(false)
|
||||
|
||||
const fetched = fetchedOrError.getValue()
|
||||
expect(fetched.payload.errorDecrypting).to.not.be.ok
|
||||
expect(fetched.payload.content.title).to.equal(changedText)
|
||||
})
|
||||
|
||||
@@ -290,6 +290,7 @@ export async function storagePayloadCount(application) {
|
||||
* Controlled via docker/syncing-server-js.env
|
||||
*/
|
||||
export const ServerRevisionFrequency = 1.1
|
||||
export const ServerRevisionCreationDelay = 1.5
|
||||
|
||||
export function yesterday() {
|
||||
return new Date(new Date().setDate(new Date().getDate() - 1))
|
||||
|
||||
Reference in New Issue
Block a user