Compare commits

...

6 Commits

Author SHA1 Message Date
standardci 2073c735a5 chore(release): publish new version
- @standardnotes/analytics@2.25.15
 - @standardnotes/api-gateway@1.71.0
 - @standardnotes/auth-server@1.133.0
 - @standardnotes/domain-events-infra@1.12.12
 - @standardnotes/domain-events@2.115.1
 - @standardnotes/event-store@1.11.21
 - @standardnotes/files-server@1.21.0
 - @standardnotes/home-server@1.14.2
 - @standardnotes/revisions-server@1.26.9
 - @standardnotes/scheduler-server@1.20.25
 - @standardnotes/security@1.10.0
 - @standardnotes/syncing-server@1.82.0
 - @standardnotes/websockets-server@1.10.19
2023-08-22 09:23:30 +00:00
Karol Sójko 34085ac6fb feat: consider shared vault owner quota when uploading files to shared vault (#704)
* fix(auth): updating storage quota on shared subscriptions

* fix(syncing-server): turn shared vault and key associations into value objects

* feat: consider shared vault owner quota when uploading files to shared vault

* fix: add passing x-shared-vault-owner-context value

* fix: refactor creating cross service token to not throw errors

* fix: caching cross service token

* fix: missing header in http service proxy
2023-08-22 10:49:58 +02:00
standardci 3d6559921b chore(release): publish new version
- @standardnotes/home-server@1.14.1
 - @standardnotes/scheduler-server@1.20.24
 - @standardnotes/syncing-server@1.81.0
2023-08-21 09:00:52 +00:00
Karol Sójko 15a7f0e71a fix(syncing-server): DocumentDB retry writes support (#703)
* fix(syncing-server): DocumentDB retry writes support

* fix: auth source for mongo
2023-08-21 10:25:56 +02:00
Karol Sójko 3e56243d6f fix(scheduler): remove exit interview form link (#702) 2023-08-21 08:42:21 +02:00
Karol Sójko 032fcb938d feat(syncing-server): add use case for migrating items from one database to another (#701) 2023-08-18 17:25:24 +02:00
93 changed files with 1182 additions and 563 deletions
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.25.15](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.25.14...@standardnotes/analytics@2.25.15) (2023-08-22)
**Note:** Version bump only for package @standardnotes/analytics
## [2.25.14](https://github.com/standardnotes/server/compare/@standardnotes/analytics@2.25.13...@standardnotes/analytics@2.25.14) (2023-08-18)
**Note:** Version bump only for package @standardnotes/analytics
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/analytics",
"version": "2.25.14",
"version": "2.25.15",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.71.0](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.70.5...@standardnotes/api-gateway@1.71.0) (2023-08-22)
### Features
* consider shared vault owner quota when uploading files to shared vault ([#704](https://github.com/standardnotes/api-gateway/issues/704)) ([34085ac](https://github.com/standardnotes/api-gateway/commit/34085ac6fb7e61d471bd3b4ae8e72112df25c3ee))
## [1.70.5](https://github.com/standardnotes/api-gateway/compare/@standardnotes/api-gateway@1.70.4...@standardnotes/api-gateway@1.70.5) (2023-08-18)
**Note:** Version bump only for package @standardnotes/api-gateway
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/api-gateway",
"version": "1.70.5",
"version": "1.71.0",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
@@ -27,16 +27,23 @@ export abstract class AuthMiddleware extends BaseMiddleware {
}
const authHeaderValue = request.headers.authorization as string
const sharedVaultOwnerContextHeaderValue = request.headers['x-shared-vault-owner-context'] as string | undefined
const cacheKey = `${authHeaderValue}${
sharedVaultOwnerContextHeaderValue ? `:${sharedVaultOwnerContextHeaderValue}` : ''
}`
try {
let crossServiceTokenFetchedFromCache = true
let crossServiceToken = null
if (this.crossServiceTokenCacheTTL) {
crossServiceToken = await this.crossServiceTokenCache.get(authHeaderValue)
crossServiceToken = await this.crossServiceTokenCache.get(cacheKey)
}
if (crossServiceToken === null) {
const authResponse = await this.serviceProxy.validateSession(authHeaderValue)
const authResponse = await this.serviceProxy.validateSession({
authorization: authHeaderValue,
sharedVaultOwnerContext: sharedVaultOwnerContextHeaderValue,
})
if (!this.handleSessionValidationResponse(authResponse, response, next)) {
return
@@ -52,7 +59,7 @@ export abstract class AuthMiddleware extends BaseMiddleware {
if (this.crossServiceTokenCacheTTL && !crossServiceTokenFetchedFromCache) {
await this.crossServiceTokenCache.set({
authorizationHeaderValue: authHeaderValue,
key: cacheKey,
encodedCrossServiceToken: crossServiceToken,
expiresAtInSeconds: this.getCrossServiceTokenCacheExpireTimestamp(decodedToken),
userUuid: decodedToken.user.uuid,
@@ -62,6 +69,7 @@ export abstract class AuthMiddleware extends BaseMiddleware {
response.locals.user = decodedToken.user
response.locals.session = decodedToken.session
response.locals.roles = decodedToken.roles
response.locals.sharedVaultOwnerContext = decodedToken.shared_vault_owner_context
} catch (error) {
const errorMessage = (error as AxiosError).isAxiosError
? JSON.stringify((error as AxiosError).response?.data)
@@ -12,29 +12,29 @@ export class InMemoryCrossServiceTokenCache implements CrossServiceTokenCacheInt
constructor(private timer: TimerInterface) {}
async set(dto: {
authorizationHeaderValue: string
key: string
encodedCrossServiceToken: string
expiresAtInSeconds: number
userUuid: string
}): Promise<void> {
let userAuthHeaders = []
const userAuthHeadersJSON = this.crossServiceTokenCache.get(`${this.USER_CST_PREFIX}:${dto.userUuid}`)
if (userAuthHeadersJSON) {
userAuthHeaders = JSON.parse(userAuthHeadersJSON)
let userKeys = []
const userKeysJSON = this.crossServiceTokenCache.get(`${this.USER_CST_PREFIX}:${dto.userUuid}`)
if (userKeysJSON) {
userKeys = JSON.parse(userKeysJSON)
}
userAuthHeaders.push(dto.authorizationHeaderValue)
userKeys.push(dto.key)
this.crossServiceTokenCache.set(`${this.USER_CST_PREFIX}:${dto.userUuid}`, JSON.stringify(userAuthHeaders))
this.crossServiceTokenCache.set(`${this.USER_CST_PREFIX}:${dto.userUuid}`, JSON.stringify(userKeys))
this.crossServiceTokenTTLCache.set(`${this.USER_CST_PREFIX}:${dto.userUuid}`, dto.expiresAtInSeconds)
this.crossServiceTokenCache.set(`${this.PREFIX}:${dto.authorizationHeaderValue}`, dto.encodedCrossServiceToken)
this.crossServiceTokenTTLCache.set(`${this.PREFIX}:${dto.authorizationHeaderValue}`, dto.expiresAtInSeconds)
this.crossServiceTokenCache.set(`${this.PREFIX}:${dto.key}`, dto.encodedCrossServiceToken)
this.crossServiceTokenTTLCache.set(`${this.PREFIX}:${dto.key}`, dto.expiresAtInSeconds)
}
async get(authorizationHeaderValue: string): Promise<string | null> {
async get(key: string): Promise<string | null> {
this.invalidateExpiredTokens()
const cachedToken = this.crossServiceTokenCache.get(`${this.PREFIX}:${authorizationHeaderValue}`)
const cachedToken = this.crossServiceTokenCache.get(`${this.PREFIX}:${key}`)
if (!cachedToken) {
return null
}
@@ -43,15 +43,15 @@ export class InMemoryCrossServiceTokenCache implements CrossServiceTokenCacheInt
}
async invalidate(userUuid: string): Promise<void> {
let userAuthorizationHeaderValues = []
const userAuthHeadersJSON = this.crossServiceTokenCache.get(`${this.USER_CST_PREFIX}:${userUuid}`)
if (userAuthHeadersJSON) {
userAuthorizationHeaderValues = JSON.parse(userAuthHeadersJSON)
let userKeyValues = []
const userKeysJSON = this.crossServiceTokenCache.get(`${this.USER_CST_PREFIX}:${userUuid}`)
if (userKeysJSON) {
userKeyValues = JSON.parse(userKeysJSON)
}
for (const authorizationHeaderValue of userAuthorizationHeaderValues) {
this.crossServiceTokenCache.delete(`${this.PREFIX}:${authorizationHeaderValue}`)
this.crossServiceTokenTTLCache.delete(`${this.PREFIX}:${authorizationHeaderValue}`)
for (const key of userKeyValues) {
this.crossServiceTokenCache.delete(`${this.PREFIX}:${key}`)
this.crossServiceTokenTTLCache.delete(`${this.PREFIX}:${key}`)
}
this.crossServiceTokenCache.delete(`${this.USER_CST_PREFIX}:${userUuid}`)
this.crossServiceTokenTTLCache.delete(`${this.USER_CST_PREFIX}:${userUuid}`)
@@ -12,32 +12,32 @@ export class RedisCrossServiceTokenCache implements CrossServiceTokenCacheInterf
constructor(@inject(TYPES.ApiGateway_Redis) private redisClient: IORedis.Redis) {}
async set(dto: {
authorizationHeaderValue: string
key: string
encodedCrossServiceToken: string
expiresAtInSeconds: number
userUuid: string
}): Promise<void> {
const pipeline = this.redisClient.pipeline()
pipeline.sadd(`${this.USER_CST_PREFIX}:${dto.userUuid}`, dto.authorizationHeaderValue)
pipeline.sadd(`${this.USER_CST_PREFIX}:${dto.userUuid}`, dto.key)
pipeline.expireat(`${this.USER_CST_PREFIX}:${dto.userUuid}`, dto.expiresAtInSeconds)
pipeline.set(`${this.PREFIX}:${dto.authorizationHeaderValue}`, dto.encodedCrossServiceToken)
pipeline.expireat(`${this.PREFIX}:${dto.authorizationHeaderValue}`, dto.expiresAtInSeconds)
pipeline.set(`${this.PREFIX}:${dto.key}`, dto.encodedCrossServiceToken)
pipeline.expireat(`${this.PREFIX}:${dto.key}`, dto.expiresAtInSeconds)
await pipeline.exec()
}
async get(authorizationHeaderValue: string): Promise<string | null> {
return this.redisClient.get(`${this.PREFIX}:${authorizationHeaderValue}`)
async get(key: string): Promise<string | null> {
return this.redisClient.get(`${this.PREFIX}:${key}`)
}
async invalidate(userUuid: string): Promise<void> {
const userAuthorizationHeaderValues = await this.redisClient.smembers(`${this.USER_CST_PREFIX}:${userUuid}`)
const userKeyValues = await this.redisClient.smembers(`${this.USER_CST_PREFIX}:${userUuid}`)
const pipeline = this.redisClient.pipeline()
for (const authorizationHeaderValue of userAuthorizationHeaderValues) {
pipeline.del(`${this.PREFIX}:${authorizationHeaderValue}`)
for (const key of userKeyValues) {
pipeline.del(`${this.PREFIX}:${key}`)
}
pipeline.del(`${this.USER_CST_PREFIX}:${userUuid}`)
@@ -1,10 +1,10 @@
export interface CrossServiceTokenCacheInterface {
set(dto: {
authorizationHeaderValue: string
key: string
encodedCrossServiceToken: string
expiresAtInSeconds: number
userUuid: string
}): Promise<void>
get(authorizationHeaderValue: string): Promise<string | null>
get(key: string): Promise<string | null>
invalidate(userUuid: string): Promise<void>
}
@@ -24,14 +24,16 @@ export class HttpServiceProxy implements ServiceProxyInterface {
@inject(TYPES.ApiGateway_Logger) private logger: Logger,
) {}
async validateSession(
authorizationHeaderValue: string,
): Promise<{ status: number; data: unknown; headers: { contentType: string } }> {
async validateSession(headers: {
authorization: string
sharedVaultOwnerContext?: string
}): Promise<{ status: number; data: unknown; headers: { contentType: string } }> {
const authResponse = await this.httpClient.request({
method: 'POST',
headers: {
Authorization: authorizationHeaderValue,
Authorization: headers.authorization,
Accept: 'application/json',
'x-shared-vault-owner-context': headers.sharedVaultOwnerContext,
},
validateStatus: (status: number) => {
return status >= 200 && status < 500
@@ -50,7 +50,7 @@ export interface ServiceProxyInterface {
endpointOrMethodIdentifier: string,
payload?: Record<string, unknown> | string,
): Promise<void>
validateSession(authorizationHeaderValue: string): Promise<{
validateSession(headers: { authorization: string; sharedVaultOwnerContext?: string }): Promise<{
status: number
data: unknown
headers: {
@@ -6,9 +6,10 @@ import { ServiceProxyInterface } from '../Http/ServiceProxyInterface'
export class DirectCallServiceProxy implements ServiceProxyInterface {
constructor(private serviceContainer: ServiceContainerInterface, private filesServerUrl: string) {}
async validateSession(
authorizationHeaderValue: string,
): Promise<{ status: number; data: unknown; headers: { contentType: string } }> {
async validateSession(headers: {
authorization: string
sharedVaultOwnerContext?: string
}): Promise<{ status: number; data: unknown; headers: { contentType: string } }> {
const authService = this.serviceContainer.get(ServiceIdentifier.create(ServiceIdentifier.NAMES.Auth).getValue())
if (!authService) {
throw new Error('Auth service not found')
@@ -17,7 +18,8 @@ export class DirectCallServiceProxy implements ServiceProxyInterface {
const serviceResponse = (await authService.handleRequest(
{
headers: {
authorization: authorizationHeaderValue,
authorization: headers.authorization,
'x-shared-vault-owner-context': headers.sharedVaultOwnerContext,
},
} as never,
{} as never,
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.133.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.132.0...@standardnotes/auth-server@1.133.0) (2023-08-22)
### Features
* consider shared vault owner quota when uploading files to shared vault ([#704](https://github.com/standardnotes/server/issues/704)) ([34085ac](https://github.com/standardnotes/server/commit/34085ac6fb7e61d471bd3b4ae8e72112df25c3ee))
# [1.132.0](https://github.com/standardnotes/server/compare/@standardnotes/auth-server@1.131.5...@standardnotes/auth-server@1.132.0) (2023-08-18)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/auth-server",
"version": "1.132.0",
"version": "1.133.0",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
@@ -8,6 +8,8 @@ import { Role } from '../../Role/Role'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { CreateCrossServiceToken } from './CreateCrossServiceToken'
import { GetSetting } from '../GetSetting/GetSetting'
import { Result } from '@standardnotes/domain-core'
describe('CreateCrossServiceToken', () => {
let userProjector: ProjectorInterface<User>
@@ -15,6 +17,7 @@ describe('CreateCrossServiceToken', () => {
let roleProjector: ProjectorInterface<Role>
let tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>
let userRepository: UserRepositoryInterface
let getSettingUseCase: GetSetting
const jwtTTL = 60
let session: Session
@@ -22,7 +25,15 @@ describe('CreateCrossServiceToken', () => {
let role: Role
const createUseCase = () =>
new CreateCrossServiceToken(userProjector, sessionProjector, roleProjector, tokenEncoder, userRepository, jwtTTL)
new CreateCrossServiceToken(
userProjector,
sessionProjector,
roleProjector,
tokenEncoder,
userRepository,
jwtTTL,
getSettingUseCase,
)
beforeEach(() => {
session = {} as jest.Mocked<Session>
@@ -50,6 +61,9 @@ describe('CreateCrossServiceToken', () => {
userRepository = {} as jest.Mocked<UserRepositoryInterface>
userRepository.findOneByUuid = jest.fn().mockReturnValue(user)
getSettingUseCase = {} as jest.Mocked<GetSetting>
getSettingUseCase.execute = jest.fn().mockReturnValue(Result.ok({ setting: { value: '100' } }))
})
it('should create a cross service token for user', async () => {
@@ -125,28 +139,74 @@ describe('CreateCrossServiceToken', () => {
it('should throw an error if user does not exist', async () => {
userRepository.findOneByUuid = jest.fn().mockReturnValue(null)
let caughtError = null
try {
await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
} catch (error) {
caughtError = error
}
const result = await createUseCase().execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(caughtError).not.toBeNull()
expect(result.isFailed()).toBeTruthy()
})
it('should throw an error if user uuid is invalid', async () => {
let caughtError = null
try {
await createUseCase().execute({
userUuid: 'invalid',
})
} catch (error) {
caughtError = error
}
const result = await createUseCase().execute({
userUuid: 'invalid',
})
expect(caughtError).not.toBeNull()
expect(result.isFailed()).toBeTruthy()
})
describe('shared vault context', () => {
it('should add shared vault context if shared vault owner uuid is provided', async () => {
await createUseCase().execute({
user,
session,
sharedVaultOwnerContext: '00000000-0000-0000-0000-000000000000',
})
expect(tokenEncoder.encodeExpirableToken).toHaveBeenCalledWith(
{
roles: [
{
name: 'role1',
uuid: '1-3-4',
},
],
session: {
test: 'test',
},
shared_vault_owner_context: {
upload_bytes_limit: 100,
},
user: {
email: 'test@test.te',
uuid: '00000000-0000-0000-0000-000000000000',
},
},
60,
)
})
it('should throw an error if shared vault owner context is sensitive', async () => {
getSettingUseCase.execute = jest.fn().mockReturnValue(Result.ok({ sensitive: true }))
const result = await createUseCase().execute({
user,
session,
sharedVaultOwnerContext: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
})
it('should throw an error if it fails to retrieve shared vault owner setting', async () => {
getSettingUseCase.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
const result = await createUseCase().execute({
user,
session,
sharedVaultOwnerContext: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
})
})
})
@@ -1,5 +1,6 @@
import { TokenEncoderInterface, CrossServiceTokenData } from '@standardnotes/security'
import { inject, injectable } from 'inversify'
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import TYPES from '../../../Bootstrap/Types'
import { ProjectorInterface } from '../../../Projection/ProjectorInterface'
@@ -7,14 +8,13 @@ import { Role } from '../../Role/Role'
import { Session } from '../../Session/Session'
import { User } from '../../User/User'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { UseCaseInterface } from '../UseCaseInterface'
import { CreateCrossServiceTokenDTO } from './CreateCrossServiceTokenDTO'
import { CreateCrossServiceTokenResponse } from './CreateCrossServiceTokenResponse'
import { Uuid } from '@standardnotes/domain-core'
import { GetSetting } from '../GetSetting/GetSetting'
import { SettingName } from '@standardnotes/settings'
@injectable()
export class CreateCrossServiceToken implements UseCaseInterface {
export class CreateCrossServiceToken implements UseCaseInterface<string> {
constructor(
@inject(TYPES.Auth_UserProjector) private userProjector: ProjectorInterface<User>,
@inject(TYPES.Auth_SessionProjector) private sessionProjector: ProjectorInterface<Session>,
@@ -22,14 +22,16 @@ export class CreateCrossServiceToken implements UseCaseInterface {
@inject(TYPES.Auth_CrossServiceTokenEncoder) private tokenEncoder: TokenEncoderInterface<CrossServiceTokenData>,
@inject(TYPES.Auth_UserRepository) private userRepository: UserRepositoryInterface,
@inject(TYPES.Auth_AUTH_JWT_TTL) private jwtTTL: number,
@inject(TYPES.Auth_GetSetting)
private getSettingUseCase: GetSetting,
) {}
async execute(dto: CreateCrossServiceTokenDTO): Promise<CreateCrossServiceTokenResponse> {
async execute(dto: CreateCrossServiceTokenDTO): Promise<Result<string>> {
let user: User | undefined | null = dto.user
if (user === undefined && dto.userUuid !== undefined) {
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
throw new Error(userUuidOrError.getError())
return Result.fail(userUuidOrError.getError())
}
const userUuid = userUuidOrError.getValue()
@@ -37,7 +39,7 @@ export class CreateCrossServiceToken implements UseCaseInterface {
}
if (!user) {
throw new Error(`Could not find user with uuid ${dto.userUuid}`)
return Result.fail(`Could not find user with uuid ${dto.userUuid}`)
}
const roles = await user.roles
@@ -45,15 +47,33 @@ export class CreateCrossServiceToken implements UseCaseInterface {
const authTokenData: CrossServiceTokenData = {
user: this.projectUser(user),
roles: this.projectRoles(roles),
shared_vault_owner_context: undefined,
}
if (dto.sharedVaultOwnerContext !== undefined) {
const uploadBytesLimitSettingOrError = await this.getSettingUseCase.execute({
settingName: SettingName.NAMES.FileUploadBytesLimit,
userUuid: dto.sharedVaultOwnerContext,
})
if (uploadBytesLimitSettingOrError.isFailed()) {
return Result.fail(uploadBytesLimitSettingOrError.getError())
}
const uploadBytesLimitSetting = uploadBytesLimitSettingOrError.getValue()
if (uploadBytesLimitSetting.sensitive) {
return Result.fail('Shared vault owner upload bytes limit setting is sensitive!')
}
const uploadBytesLimit = parseInt(uploadBytesLimitSetting.setting.value as string)
authTokenData.shared_vault_owner_context = {
upload_bytes_limit: uploadBytesLimit,
}
}
if (dto.session !== undefined) {
authTokenData.session = this.projectSession(dto.session)
}
return {
token: this.tokenEncoder.encodeExpirableToken(authTokenData, this.jwtTTL),
}
return Result.ok(this.tokenEncoder.encodeExpirableToken(authTokenData, this.jwtTTL))
}
private projectUser(user: User): { uuid: string; email: string } {
@@ -6,6 +6,7 @@ export type CreateCrossServiceTokenDTO = Either<
{
user: User
session?: Session
sharedVaultOwnerContext?: string
},
{
userUuid: string
@@ -1,3 +0,0 @@
export type CreateCrossServiceTokenResponse = {
token: string
}
@@ -73,35 +73,30 @@ describe('GetSetting', () => {
describe('no subscription', () => {
it('should find a setting for user', async () => {
expect(
await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.NAMES.DropboxBackupFrequency }),
).toEqual({
success: true,
const result = await createUseCase().execute({
userUuid: '1-2-3',
settingName: SettingName.NAMES.DropboxBackupFrequency,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
userUuid: '1-2-3',
setting: { foo: 'bar' },
})
})
it('should not find a setting if the setting name is invalid', async () => {
expect(await createUseCase().execute({ userUuid: '1-2-3', settingName: 'invalid' })).toEqual({
success: false,
error: {
message: 'Invalid setting name: invalid',
},
})
const result = await createUseCase().execute({ userUuid: '1-2-3', settingName: 'invalid' })
expect(result.isFailed()).toBeTruthy()
})
it('should not get a setting for user if it does not exist', async () => {
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(null)
expect(
await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.NAMES.DropboxBackupFrequency }),
).toEqual({
success: false,
error: {
message: 'Setting DROPBOX_BACKUP_FREQUENCY for user 1-2-3 not found!',
},
const result = await createUseCase().execute({
userUuid: '1-2-3',
settingName: SettingName.NAMES.DropboxBackupFrequency,
})
expect(result.isFailed()).toBeTruthy()
})
it('should not retrieve a sensitive setting for user', async () => {
@@ -112,21 +107,19 @@ describe('GetSetting', () => {
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting)
expect(await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.NAMES.MfaSecret })).toEqual({
success: true,
const result = await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.NAMES.MfaSecret })
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
sensitive: true,
})
})
it('should not retrieve a subscription setting for user', async () => {
expect(
await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.NAMES.MuteSignInEmails }),
).toEqual({
success: false,
error: {
message: 'No subscription found.',
},
const result = await createUseCase().execute({
userUuid: '1-2-3',
settingName: SettingName.NAMES.MuteSignInEmails,
})
expect(result.isFailed()).toBeTruthy()
})
it('should retrieve a sensitive setting for user if explicitly told to', async () => {
@@ -137,14 +130,13 @@ describe('GetSetting', () => {
settingService.findSettingWithDecryptedValue = jest.fn().mockReturnValue(setting)
expect(
await createUseCase().execute({
userUuid: '1-2-3',
settingName: SettingName.NAMES.MfaSecret,
allowSensitiveRetrieval: true,
}),
).toEqual({
success: true,
const result = await createUseCase().execute({
userUuid: '1-2-3',
settingName: SettingName.NAMES.MfaSecret,
allowSensitiveRetrieval: true,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
userUuid: '1-2-3',
setting: { foo: 'bar' },
})
@@ -159,10 +151,12 @@ describe('GetSetting', () => {
})
it('should find a setting for user', async () => {
expect(
await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.NAMES.MuteSignInEmails }),
).toEqual({
success: true,
const result = await createUseCase().execute({
userUuid: '1-2-3',
settingName: SettingName.NAMES.MuteSignInEmails,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
userUuid: '1-2-3',
setting: { foo: 'sub-bar' },
})
@@ -171,14 +165,11 @@ describe('GetSetting', () => {
it('should not get a suscription setting for user if it does not exist', async () => {
subscriptionSettingService.findSubscriptionSettingWithDecryptedValue = jest.fn().mockReturnValue(null)
expect(
await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.NAMES.MuteSignInEmails }),
).toEqual({
success: false,
error: {
message: 'Subscription setting MUTE_SIGN_IN_EMAILS for user 1-2-3 not found!',
},
const result = await createUseCase().execute({
userUuid: '1-2-3',
settingName: SettingName.NAMES.MuteSignInEmails,
})
expect(result.isFailed()).toBeTruthy()
})
it('should not retrieve a sensitive subscription setting for user', async () => {
@@ -188,10 +179,12 @@ describe('GetSetting', () => {
.fn()
.mockReturnValue(subscriptionSetting)
expect(
await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.NAMES.MuteSignInEmails }),
).toEqual({
success: true,
const result = await createUseCase().execute({
userUuid: '1-2-3',
settingName: SettingName.NAMES.MuteSignInEmails,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
sensitive: true,
})
})
@@ -205,10 +198,12 @@ describe('GetSetting', () => {
})
it('should find a setting for user', async () => {
expect(
await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.NAMES.MuteSignInEmails }),
).toEqual({
success: true,
const result = await createUseCase().execute({
userUuid: '1-2-3',
settingName: SettingName.NAMES.MuteSignInEmails,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
userUuid: '1-2-3',
setting: { foo: 'sub-bar' },
})
@@ -221,10 +216,12 @@ describe('GetSetting', () => {
})
it('should find a regular subscription only setting for user', async () => {
expect(
await createUseCase().execute({ userUuid: '1-2-3', settingName: SettingName.NAMES.FileUploadBytesLimit }),
).toEqual({
success: true,
const result = await createUseCase().execute({
userUuid: '1-2-3',
settingName: SettingName.NAMES.FileUploadBytesLimit,
})
expect(result.isFailed()).toBeFalsy()
expect(result.getValue()).toEqual({
userUuid: '1-2-3',
setting: { foo: 'sub-bar' },
})
@@ -1,7 +1,7 @@
import { SettingName } from '@standardnotes/settings'
import { inject, injectable } from 'inversify'
import { Result, UseCaseInterface } from '@standardnotes/domain-core'
import { UseCaseInterface } from '../UseCaseInterface'
import TYPES from '../../../Bootstrap/Types'
import { SettingProjector } from '../../../Projection/SettingProjector'
import { SettingServiceInterface } from '../../Setting/SettingServiceInterface'
@@ -14,7 +14,7 @@ import { GetSettingResponse } from './GetSettingResponse'
import { UserSubscription } from '../../Subscription/UserSubscription'
@injectable()
export class GetSetting implements UseCaseInterface {
export class GetSetting implements UseCaseInterface<GetSettingResponse> {
constructor(
@inject(TYPES.Auth_SettingProjector) private settingProjector: SettingProjector,
@inject(TYPES.Auth_SubscriptionSettingProjector) private subscriptionSettingProjector: SubscriptionSettingProjector,
@@ -24,15 +24,10 @@ export class GetSetting implements UseCaseInterface {
@inject(TYPES.Auth_UserSubscriptionService) private userSubscriptionService: UserSubscriptionServiceInterface,
) {}
async execute(dto: GetSettingDto): Promise<GetSettingResponse> {
async execute(dto: GetSettingDto): Promise<Result<GetSettingResponse>> {
const settingNameOrError = SettingName.create(dto.settingName)
if (settingNameOrError.isFailed()) {
return {
success: false,
error: {
message: settingNameOrError.getError(),
},
}
return Result.fail(settingNameOrError.getError())
}
const settingName = settingNameOrError.getValue()
@@ -47,12 +42,7 @@ export class GetSetting implements UseCaseInterface {
}
if (!subscription) {
return {
success: false,
error: {
message: 'No subscription found.',
},
}
return Result.fail('No subscription found.')
}
const subscriptionSetting = await this.subscriptionSettingService.findSubscriptionSettingWithDecryptedValue({
@@ -62,28 +52,21 @@ export class GetSetting implements UseCaseInterface {
})
if (subscriptionSetting === null) {
return {
success: false,
error: {
message: `Subscription setting ${settingName.value} for user ${dto.userUuid} not found!`,
},
}
return Result.fail(`Subscription setting ${settingName.value} for user ${dto.userUuid} not found!`)
}
if (subscriptionSetting.sensitive && !dto.allowSensitiveRetrieval) {
return {
success: true,
return Result.ok({
sensitive: true,
}
})
}
const simpleSubscriptionSetting = await this.subscriptionSettingProjector.projectSimple(subscriptionSetting)
return {
success: true,
return Result.ok({
userUuid: dto.userUuid,
setting: simpleSubscriptionSetting,
}
})
}
const setting = await this.settingService.findSettingWithDecryptedValue({
@@ -92,27 +75,20 @@ export class GetSetting implements UseCaseInterface {
})
if (setting === null) {
return {
success: false,
error: {
message: `Setting ${settingName.value} for user ${dto.userUuid} not found!`,
},
}
return Result.fail(`Setting ${settingName.value} for user ${dto.userUuid} not found!`)
}
if (setting.sensitive && !dto.allowSensitiveRetrieval) {
return {
success: true,
return Result.ok({
sensitive: true,
}
})
}
const simpleSetting = await this.settingProjector.projectSimple(setting)
return {
success: true,
return Result.ok({
userUuid: dto.userUuid,
setting: simpleSetting,
}
})
}
}
@@ -1,18 +1,13 @@
import { Either } from '@standardnotes/common'
import { SimpleSetting } from '../../Setting/SimpleSetting'
export type GetSettingResponse =
| {
success: true
userUuid: string
setting: SimpleSetting
}
| {
success: true
sensitive: true
}
| {
success: false
error: {
message: string
}
}
export type GetSettingResponse = Either<
{
userUuid: string
setting: SimpleSetting
},
{
sensitive: true
}
>
@@ -7,7 +7,6 @@ import { UserSubscription } from '../../Subscription/UserSubscription'
import { UserSubscriptionServiceInterface } from '../../Subscription/UserSubscriptionServiceInterface'
import { UserRepositoryInterface } from '../../User/UserRepositoryInterface'
import { UpdateStorageQuotaUsedForUserDTO } from './UpdateStorageQuotaUsedForUserDTO'
import { User } from '../../User/User'
export class UpdateStorageQuotaUsedForUser implements UseCaseInterface<void> {
constructor(
@@ -34,23 +33,20 @@ export class UpdateStorageQuotaUsedForUser implements UseCaseInterface<void> {
return Result.fail(`Could not find regular user subscription for user with uuid: ${userUuid.value}`)
}
await this.updateUploadBytesUsedSetting(regularSubscription, user, dto.bytesUsed)
await this.updateUploadBytesUsedSetting(regularSubscription, dto.bytesUsed)
if (sharedSubscription !== null) {
await this.updateUploadBytesUsedSetting(sharedSubscription, user, dto.bytesUsed)
await this.updateUploadBytesUsedSetting(sharedSubscription, dto.bytesUsed)
}
return Result.ok()
}
private async updateUploadBytesUsedSetting(
subscription: UserSubscription,
user: User,
bytesUsed: number,
): Promise<void> {
private async updateUploadBytesUsedSetting(subscription: UserSubscription, bytesUsed: number): Promise<void> {
let bytesAlreadyUsed = '0'
const subscriptionUser = await subscription.user
const bytesUsedSetting = await this.subscriptionSettingService.findSubscriptionSettingWithDecryptedValue({
userUuid: (await subscription.user).uuid,
userUuid: subscriptionUser.uuid,
userSubscriptionUuid: subscription.uuid,
subscriptionSettingName: SettingName.create(SettingName.NAMES.FileUploadBytesUsed).getValue(),
})
@@ -60,7 +56,7 @@ export class UpdateStorageQuotaUsedForUser implements UseCaseInterface<void> {
await this.subscriptionSettingService.createOrReplace({
userSubscription: subscription,
user,
user: subscriptionUser,
props: {
name: SettingName.NAMES.FileUploadBytesUsed,
unencryptedValue: (+bytesAlreadyUsed + bytesUsed).toString(),
@@ -7,6 +7,7 @@ import { results } from 'inversify-express-utils'
import { User } from '../../Domain/User/User'
import { GetUserFeatures } from '../../Domain/UseCase/GetUserFeatures/GetUserFeatures'
import { GetSetting } from '../../Domain/UseCase/GetSetting/GetSetting'
import { Result } from '@standardnotes/domain-core'
describe('AnnotatedInternalController', () => {
let getUserFeatures: GetUserFeatures
@@ -73,7 +74,7 @@ describe('AnnotatedInternalController', () => {
request.params.userUuid = '1-2-3'
request.params.settingName = 'foobar'
getSetting.execute = jest.fn().mockReturnValue({ success: true })
getSetting.execute = jest.fn().mockReturnValue(Result.ok())
const httpResponse = <results.JsonResult>await createController().getSetting(request)
const result = await httpResponse.executeAsync()
@@ -91,7 +92,7 @@ describe('AnnotatedInternalController', () => {
request.params.userUuid = '1-2-3'
request.params.settingName = 'foobar'
getSetting.execute = jest.fn().mockReturnValue({ success: false })
getSetting.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
const httpResponse = <results.JsonResult>await createController().getSetting(request)
const result = await httpResponse.executeAsync()
@@ -36,16 +36,26 @@ export class AnnotatedInternalController extends BaseHttpController {
@httpGet('/users/:userUuid/settings/:settingName')
async getSetting(request: Request): Promise<results.JsonResult> {
const result = await this.doGetSetting.execute({
const resultOrError = await this.doGetSetting.execute({
userUuid: request.params.userUuid,
settingName: request.params.settingName,
allowSensitiveRetrieval: true,
})
if (result.success) {
return this.json(result)
if (resultOrError.isFailed()) {
return this.json(
{
error: {
message: resultOrError.getError(),
},
},
400,
)
}
return this.json(result, 400)
return this.json({
success: true,
...resultOrError.getValue(),
})
}
}
@@ -11,6 +11,7 @@ import { CreateCrossServiceToken } from '../../Domain/UseCase/CreateCrossService
import { GetActiveSessionsForUser } from '../../Domain/UseCase/GetActiveSessionsForUser'
import { ProjectorInterface } from '../../Projection/ProjectorInterface'
import { Session } from '../../Domain/Session/Session'
import { Result } from '@standardnotes/domain-core'
describe('AnnotatedSessionsController', () => {
let getActiveSessionsForUser: GetActiveSessionsForUser
@@ -45,7 +46,7 @@ describe('AnnotatedSessionsController', () => {
sessionProjector.projectCustom = jest.fn().mockReturnValue({ foo: 'bar' })
createCrossServiceToken = {} as jest.Mocked<CreateCrossServiceToken>
createCrossServiceToken.execute = jest.fn().mockReturnValue({ token: 'foobar' })
createCrossServiceToken.execute = jest.fn().mockReturnValue(Result.ok('foobar'))
request = {
params: {},
@@ -10,6 +10,7 @@ import { GetSetting } from '../../Domain/UseCase/GetSetting/GetSetting'
import { GetSettings } from '../../Domain/UseCase/GetSettings/GetSettings'
import { UpdateSetting } from '../../Domain/UseCase/UpdateSetting/UpdateSetting'
import { User } from '../../Domain/User/User'
import { Result } from '@standardnotes/domain-core'
describe('AnnotatedSettingsController', () => {
let deleteSetting: DeleteSetting
@@ -85,7 +86,7 @@ describe('AnnotatedSettingsController', () => {
uuid: '1-2-3',
}
getSetting.execute = jest.fn().mockReturnValue({ success: true })
getSetting.execute = jest.fn().mockReturnValue(Result.ok())
const httpResponse = <results.JsonResult>await createController().getSetting(request, response)
const result = await httpResponse.executeAsync()
@@ -119,7 +120,7 @@ describe('AnnotatedSettingsController', () => {
uuid: '1-2-3',
}
getSetting.execute = jest.fn().mockReturnValue({ success: false })
getSetting.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
const httpResponse = <results.JsonResult>await createController().getSetting(request, response)
const result = await httpResponse.executeAsync()
@@ -6,6 +6,7 @@ import { results } from 'inversify-express-utils'
import { AnnotatedSubscriptionSettingsController } from './AnnotatedSubscriptionSettingsController'
import { User } from '../../Domain/User/User'
import { GetSetting } from '../../Domain/UseCase/GetSetting/GetSetting'
import { Result } from '@standardnotes/domain-core'
describe('AnnotatedSubscriptionSettingsController', () => {
let getSetting: GetSetting
@@ -41,7 +42,7 @@ describe('AnnotatedSubscriptionSettingsController', () => {
uuid: '1-2-3',
}
getSetting.execute = jest.fn().mockReturnValue({ success: true })
getSetting.execute = jest.fn().mockReturnValue(Result.ok())
const httpResponse = <results.JsonResult>await createController().getSubscriptionSetting(request, response)
const result = await httpResponse.executeAsync()
@@ -58,7 +59,7 @@ describe('AnnotatedSubscriptionSettingsController', () => {
uuid: '1-2-3',
}
getSetting.execute = jest.fn().mockReturnValue({ success: false })
getSetting.execute = jest.fn().mockReturnValue(Result.fail('Oops'))
const httpResponse = <results.JsonResult>await createController().getSubscriptionSetting(request, response)
const result = await httpResponse.executeAsync()
@@ -45,12 +45,25 @@ export class BaseSessionsController extends BaseHttpController {
const user = authenticateRequestResponse.user as User
const result = await this.createCrossServiceToken.execute({
const sharedVaultOwnerContext = request.headers['x-shared-vault-owner-context'] as string | undefined
const resultOrError = await this.createCrossServiceToken.execute({
user,
session: authenticateRequestResponse.session,
sharedVaultOwnerContext,
})
if (resultOrError.isFailed()) {
return this.json(
{
error: {
message: resultOrError.getError(),
},
},
400,
)
}
return this.json({ authToken: result.token })
return this.json({ authToken: resultOrError.getValue() })
}
async getSessions(_request: Request, response: Response): Promise<results.JsonResult> {
@@ -58,13 +58,22 @@ export class BaseSettingsController extends BaseHttpController {
}
const { userUuid, settingName } = request.params
const result = await this.doGetSetting.execute({ userUuid, settingName: settingName.toUpperCase() })
if (result.success) {
return this.json(result)
const resultOrError = await this.doGetSetting.execute({ userUuid, settingName: settingName.toUpperCase() })
if (resultOrError.isFailed()) {
return this.json(
{
error: {
message: resultOrError.getError(),
},
},
400,
)
}
return this.json(result, 400)
return this.json({
success: true,
...resultOrError.getValue(),
})
}
async updateSetting(request: Request, response: Response): Promise<results.JsonResult | results.StatusCodeResult> {
@@ -14,15 +14,25 @@ export class BaseSubscriptionSettingsController extends BaseHttpController {
}
async getSubscriptionSetting(request: Request, response: Response): Promise<results.JsonResult> {
const result = await this.doGetSetting.execute({
const resultOrError = await this.doGetSetting.execute({
userUuid: response.locals.user.uuid,
settingName: request.params.subscriptionSettingName.toUpperCase(),
})
if (result.success) {
return this.json(result)
if (resultOrError.isFailed()) {
return this.json(
{
error: {
message: resultOrError.getError(),
},
},
400,
)
}
return this.json(result, 400)
return this.json({
success: true,
...resultOrError.getValue(),
})
}
}
@@ -46,10 +46,20 @@ export class BaseWebSocketsController extends BaseHttpController {
)
}
const result = await this.createCrossServiceToken.execute({
const resultOrError = await this.createCrossServiceToken.execute({
userUuid: token.userUuid,
})
if (resultOrError.isFailed()) {
return this.json(
{
error: {
message: resultOrError.getError(),
},
},
400,
)
}
return this.json({ authToken: result.token })
return this.json({ authToken: resultOrError.getValue() })
}
}
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.12.12](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.12.11...@standardnotes/domain-events-infra@1.12.12) (2023-08-22)
**Note:** Version bump only for package @standardnotes/domain-events-infra
## [1.12.11](https://github.com/standardnotes/server/compare/@standardnotes/domain-events-infra@1.12.10...@standardnotes/domain-events-infra@1.12.11) (2023-08-08)
**Note:** Version bump only for package @standardnotes/domain-events-infra
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/domain-events-infra",
"version": "1.12.11",
"version": "1.12.12",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [2.115.1](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.115.0...@standardnotes/domain-events@2.115.1) (2023-08-22)
**Note:** Version bump only for package @standardnotes/domain-events
# [2.115.0](https://github.com/standardnotes/server/compare/@standardnotes/domain-events@2.114.0...@standardnotes/domain-events@2.115.0) (2023-08-08)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/domain-events",
"version": "2.115.0",
"version": "2.115.1",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.11.21](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.11.20...@standardnotes/event-store@1.11.21) (2023-08-22)
**Note:** Version bump only for package @standardnotes/event-store
## [1.11.20](https://github.com/standardnotes/server/compare/@standardnotes/event-store@1.11.19...@standardnotes/event-store@1.11.20) (2023-08-18)
**Note:** Version bump only for package @standardnotes/event-store
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/event-store",
"version": "1.11.20",
"version": "1.11.21",
"description": "Event Store Service",
"private": true,
"main": "dist/src/index.js",
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.21.0](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.20.4...@standardnotes/files-server@1.21.0) (2023-08-22)
### Features
* consider shared vault owner quota when uploading files to shared vault ([#704](https://github.com/standardnotes/files/issues/704)) ([34085ac](https://github.com/standardnotes/files/commit/34085ac6fb7e61d471bd3b4ae8e72112df25c3ee))
## [1.20.4](https://github.com/standardnotes/files/compare/@standardnotes/files-server@1.20.3...@standardnotes/files-server@1.20.4) (2023-08-18)
**Note:** Version bump only for package @standardnotes/files-server
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/files-server",
"version": "1.20.4",
"version": "1.21.0",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
@@ -121,6 +121,10 @@ export class AnnotatedSharedVaultFilesController extends BaseHttpController {
return this.badRequest('Not permitted for this operation')
}
if (locals.uploadBytesLimit === undefined) {
return this.badRequest('Missing upload bytes limit')
}
const result = await this.finishUploadSession.execute({
userUuid: locals.vaultOwnerUuid,
sharedVaultUuid: locals.sharedVaultUuid,
@@ -1,4 +1,4 @@
import { SharedVaultValetTokenData, TokenDecoderInterface } from '@standardnotes/security'
import { SharedVaultValetTokenData, TokenDecoderInterface, ValetTokenOperation } from '@standardnotes/security'
import { Uuid } from '@standardnotes/domain-core'
import { NextFunction, Request, Response } from 'express'
import { inject, injectable } from 'inversify'
@@ -61,6 +61,17 @@ export class SharedVaultValetTokenAuthMiddleware extends BaseMiddleware {
return
}
if (this.userHasNoSpaceToUpload(valetTokenData)) {
response.status(403).send({
error: {
tag: 'no-space',
message: 'The file you are trying to upload is too big. Please ask the vault owner to upgrade subscription',
},
})
return
}
const whitelistedData: SharedVaultValetTokenData = {
sharedVaultUuid: valetTokenData.sharedVaultUuid,
vaultOwnerUuid: valetTokenData.vaultOwnerUuid,
@@ -79,4 +90,32 @@ export class SharedVaultValetTokenAuthMiddleware extends BaseMiddleware {
return next(error)
}
}
private userHasNoSpaceToUpload(valetTokenData: SharedVaultValetTokenData) {
if (![ValetTokenOperation.Write, ValetTokenOperation.Move].includes(valetTokenData.permittedOperation)) {
return false
}
if (valetTokenData.uploadBytesLimit === -1) {
return false
}
const isMovingToNonSharedVault =
valetTokenData.permittedOperation === ValetTokenOperation.Move &&
valetTokenData.moveOperation?.type === 'shared-vault-to-user'
if (isMovingToNonSharedVault) {
return false
}
if (valetTokenData.uploadBytesLimit === undefined) {
return true
}
const remainingUploadSpace = valetTokenData.uploadBytesLimit - valetTokenData.uploadBytesUsed
const consideredUploadSize = valetTokenData.unencryptedFileSize as number
return remainingUploadSpace - consideredUploadSize <= 0
}
}
+8
View File
@@ -3,6 +3,14 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.14.2](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.14.1...@standardnotes/home-server@1.14.2) (2023-08-22)
**Note:** Version bump only for package @standardnotes/home-server
## [1.14.1](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.14.0...@standardnotes/home-server@1.14.1) (2023-08-21)
**Note:** Version bump only for package @standardnotes/home-server
# [1.14.0](https://github.com/standardnotes/server/compare/@standardnotes/home-server@1.13.51...@standardnotes/home-server@1.14.0) (2023-08-18)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/home-server",
"version": "1.14.0",
"version": "1.14.2",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.26.9](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.26.8...@standardnotes/revisions-server@1.26.9) (2023-08-22)
**Note:** Version bump only for package @standardnotes/revisions-server
## [1.26.8](https://github.com/standardnotes/server/compare/@standardnotes/revisions-server@1.26.7...@standardnotes/revisions-server@1.26.8) (2023-08-18)
**Note:** Version bump only for package @standardnotes/revisions-server
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/revisions-server",
"version": "1.26.8",
"version": "1.26.9",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
+10
View File
@@ -3,6 +3,16 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.20.25](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.20.24...@standardnotes/scheduler-server@1.20.25) (2023-08-22)
**Note:** Version bump only for package @standardnotes/scheduler-server
## [1.20.24](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.20.23...@standardnotes/scheduler-server@1.20.24) (2023-08-21)
### Bug Fixes
* **scheduler:** remove exit interview form link ([#702](https://github.com/standardnotes/server/issues/702)) ([3e56243](https://github.com/standardnotes/server/commit/3e56243d6f72f5ac86d4fb2191349ec3a589bc83))
## [1.20.23](https://github.com/standardnotes/server/compare/@standardnotes/scheduler-server@1.20.22...@standardnotes/scheduler-server@1.20.23) (2023-08-18)
**Note:** Version bump only for package @standardnotes/scheduler-server
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/scheduler-server",
"version": "1.20.23",
"version": "1.20.25",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
@@ -15,8 +15,6 @@ export const html = `<div>
are willing to pay for a product is most crucial for us as we continue to evolve and iterate on Standard
Notes.
</p>
<p>If you have a minute, please fill out this brief exit interview: </p>
<a href="https://standardnotes.typeform.com/to/dX5lzPtm">Short Exit Interview </a>
<p>
Our team reads every single response, and your feedback will be shared with the relevant department within our
team.
+6
View File
@@ -3,6 +3,12 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.10.0](https://github.com/standardnotes/server/compare/@standardnotes/security@1.9.0...@standardnotes/security@1.10.0) (2023-08-22)
### Features
* consider shared vault owner quota when uploading files to shared vault ([#704](https://github.com/standardnotes/server/issues/704)) ([34085ac](https://github.com/standardnotes/server/commit/34085ac6fb7e61d471bd3b4ae8e72112df25c3ee))
# [1.9.0](https://github.com/standardnotes/server/compare/@standardnotes/security@1.8.1...@standardnotes/security@1.9.0) (2023-08-08)
### Features
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/security",
"version": "1.9.0",
"version": "1.10.0",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
@@ -5,6 +5,9 @@ export type CrossServiceTokenData = {
uuid: string
email: string
}
shared_vault_owner_context?: {
upload_bytes_limit: number
}
roles: Array<Role>
session?: {
uuid: string
@@ -8,7 +8,7 @@ export interface SharedVaultValetTokenData {
remoteIdentifier: string
unencryptedFileSize?: number
uploadBytesUsed: number
uploadBytesLimit: number
uploadBytesLimit?: number
moveOperation?: {
type: SharedVaultMoveType
fromUuid: string
+16
View File
@@ -3,6 +3,22 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
# [1.82.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.81.0...@standardnotes/syncing-server@1.82.0) (2023-08-22)
### Features
* consider shared vault owner quota when uploading files to shared vault ([#704](https://github.com/standardnotes/syncing-server-js/issues/704)) ([34085ac](https://github.com/standardnotes/syncing-server-js/commit/34085ac6fb7e61d471bd3b4ae8e72112df25c3ee))
# [1.81.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.80.0...@standardnotes/syncing-server@1.81.0) (2023-08-21)
### Bug Fixes
* **syncing-server:** DocumentDB retry writes support ([#703](https://github.com/standardnotes/syncing-server-js/issues/703)) ([15a7f0e](https://github.com/standardnotes/syncing-server-js/commit/15a7f0e71ac2f6c355fb73208559a8fd822773aa))
### Features
* **syncing-server:** add use case for migrating items from one database to another ([#701](https://github.com/standardnotes/syncing-server-js/issues/701)) ([032fcb9](https://github.com/standardnotes/syncing-server-js/commit/032fcb938d9f81381dd9879af4bda9254ee8c499))
# [1.80.0](https://github.com/standardnotes/syncing-server-js/compare/@standardnotes/syncing-server@1.79.1...@standardnotes/syncing-server@1.80.0) (2023-08-18)
### Features
@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class RemoveSharedVaultLimit1692619430384 implements MigrationInterface {
name = 'RemoveSharedVaultLimit1692619430384'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `shared_vaults` DROP COLUMN `file_upload_bytes_limit`')
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `shared_vaults` ADD `file_upload_bytes_limit` int NOT NULL')
}
}
@@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class RemoveSharedVaultLimit1692619677621 implements MigrationInterface {
name = 'RemoveSharedVaultLimit1692619677621'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "shared_vaults" RENAME TO "temporary_shared_vaults"')
await queryRunner.query(
'CREATE TABLE "shared_vaults" ("uuid" varchar PRIMARY KEY NOT NULL, "user_uuid" varchar(36) NOT NULL, "file_upload_bytes_used" integer NOT NULL, "created_at_timestamp" bigint NOT NULL, "updated_at_timestamp" bigint NOT NULL)',
)
await queryRunner.query(
'INSERT INTO "shared_vaults"("uuid", "user_uuid", "file_upload_bytes_used", "created_at_timestamp", "updated_at_timestamp") SELECT "uuid", "user_uuid", "file_upload_bytes_used", "created_at_timestamp", "updated_at_timestamp" FROM "temporary_shared_vaults"',
)
await queryRunner.query('DROP TABLE "temporary_shared_vaults"')
}
public async down(): Promise<void> {
return
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/syncing-server",
"version": "1.80.0",
"version": "1.82.0",
"engines": {
"node": ">=18.0.0 <21.0.0"
},
@@ -78,8 +78,6 @@ import { SaveItems } from '../Domain/UseCase/Syncing/SaveItems/SaveItems'
import { ItemHashHttpMapper } from '../Mapping/Http/ItemHashHttpMapper'
import { ItemHash } from '../Domain/Item/ItemHash'
import { ItemHashHttpRepresentation } from '../Mapping/Http/ItemHashHttpRepresentation'
import { TypeORMKeySystemAssociation } from '../Infra/TypeORM/TypeORMKeySystemAssociation'
import { TypeORMSharedVaultAssociation } from '../Infra/TypeORM/TypeORMSharedVaultAssociation'
import { BaseSharedVaultInvitesController } from '../Infra/InversifyExpressUtils/Base/BaseSharedVaultInvitesController'
import { InviteUserToSharedVault } from '../Domain/UseCase/SharedVaults/InviteUserToSharedVault/InviteUserToSharedVault'
import { TypeORMSharedVaultRepository } from '../Infra/TypeORM/TypeORMSharedVaultRepository'
@@ -156,6 +154,7 @@ import { MongoDBItemPersistenceMapper } from '../Mapping/Persistence/MongoDB/Mon
import { Logger } from 'winston'
import { ItemRepositoryResolverInterface } from '../Domain/Item/ItemRepositoryResolverInterface'
import { TypeORMItemRepositoryResolver } from '../Infra/TypeORM/TypeORMItemRepositoryResolver'
import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from '../Domain/UseCase/Transition/TransitionItemsFromPrimaryToSecondaryDatabaseForUser/TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
export class ContainerConfigLoader {
private readonly DEFAULT_CONTENT_SIZE_TRANSFER_LIMIT = 10_000_000
@@ -360,12 +359,6 @@ export class ContainerConfigLoader {
container
.bind<Repository<TypeORMItem>>(TYPES.Sync_ORMItemRepository)
.toDynamicValue(() => appDataSource.getRepository(TypeORMItem))
container
.bind<Repository<TypeORMSharedVaultAssociation>>(TYPES.Sync_ORMSharedVaultAssociationRepository)
.toConstantValue(appDataSource.getRepository(TypeORMSharedVaultAssociation))
container
.bind<Repository<TypeORMKeySystemAssociation>>(TYPES.Sync_ORMKeySystemAssociationRepository)
.toConstantValue(appDataSource.getRepository(TypeORMKeySystemAssociation))
container
.bind<Repository<TypeORMSharedVault>>(TYPES.Sync_ORMSharedVaultRepository)
.toConstantValue(appDataSource.getRepository(TypeORMSharedVault))
@@ -775,6 +768,15 @@ export class ContainerConfigLoader {
container.get<SharedVaultRepositoryInterface>(TYPES.Sync_SharedVaultRepository),
),
)
container
.bind(TransitionItemsFromPrimaryToSecondaryDatabaseForUser)
.toConstantValue(
new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(
container.get<ItemRepositoryInterface>(TYPES.Sync_MySQLItemRepository),
isSecondaryDatabaseEnabled ? container.get<ItemRepositoryInterface>(TYPES.Sync_MongoDBItemRepository) : null,
container.get<Logger>(TYPES.Sync_Logger),
),
)
// Services
container
@@ -4,8 +4,6 @@ import { Env } from './Env'
import { SqliteConnectionOptions } from 'typeorm/driver/sqlite/SqliteConnectionOptions'
import { TypeORMItem } from '../Infra/TypeORM/TypeORMItem'
import { TypeORMNotification } from '../Infra/TypeORM/TypeORMNotification'
import { TypeORMSharedVaultAssociation } from '../Infra/TypeORM/TypeORMSharedVaultAssociation'
import { TypeORMKeySystemAssociation } from '../Infra/TypeORM/TypeORMKeySystemAssociation'
import { TypeORMSharedVault } from '../Infra/TypeORM/TypeORMSharedVault'
import { TypeORMSharedVaultUser } from '../Infra/TypeORM/TypeORMSharedVaultUser'
import { TypeORMSharedVaultInvite } from '../Infra/TypeORM/TypeORMSharedVaultInvite'
@@ -58,6 +56,7 @@ export class AppDataSource {
password: this.env.get('MONGO_PASSWORD', true),
database: this.env.get('MONGO_DATABASE'),
entities: [MongoDBItem],
retryWrites: false,
synchronize: true,
})
@@ -78,8 +77,6 @@ export class AppDataSource {
entities: [
TypeORMItem,
TypeORMNotification,
TypeORMSharedVaultAssociation,
TypeORMKeySystemAssociation,
TypeORMSharedVault,
TypeORMSharedVaultUser,
TypeORMSharedVaultInvite,
@@ -17,8 +17,6 @@ const TYPES = {
Sync_MessageRepository: Symbol.for('Sync_MessageRepository'),
// ORM
Sync_ORMItemRepository: Symbol.for('Sync_ORMItemRepository'),
Sync_ORMSharedVaultAssociationRepository: Symbol.for('Sync_ORMSharedVaultAssociationRepository'),
Sync_ORMKeySystemAssociationRepository: Symbol.for('Sync_ORMKeySystemAssociationRepository'),
Sync_ORMSharedVaultRepository: Symbol.for('Sync_ORMSharedVaultRepository'),
Sync_ORMSharedVaultInviteRepository: Symbol.for('Sync_ORMSharedVaultInviteRepository'),
Sync_ORMSharedVaultUserRepository: Symbol.for('Sync_ORMSharedVaultUserRepository'),
@@ -82,6 +80,9 @@ const TYPES = {
Sync_DetermineSharedVaultOperationOnItem: Symbol.for('Sync_DetermineSharedVaultOperationOnItem'),
Sync_UpdateStorageQuotaUsedInSharedVault: Symbol.for('Sync_UpdateStorageQuotaUsedInSharedVault'),
Sync_AddNotificationsForUsers: Symbol.for('Sync_AddNotificationsForUsers'),
Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser: Symbol.for(
'Sync_TransitionItemsFromPrimaryToSecondaryDatabaseForUser',
),
// Handlers
Sync_AccountDeletionRequestedEventHandler: Symbol.for('Sync_AccountDeletionRequestedEventHandler'),
Sync_DuplicateItemSyncedEventHandler: Symbol.for('Sync_DuplicateItemSyncedEventHandler'),
@@ -110,9 +110,7 @@ describe('Item', () => {
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
keySystemAssociation: KeySystemAssociation.create({
keySystemIdentifier: 'key-system-identifier',
}).getValue(),
keySystemAssociation: KeySystemAssociation.create('key-system-identifier').getValue(),
})
expect(entityOrError.isFailed()).toBeFalsy()
@@ -138,103 +136,117 @@ describe('Item', () => {
expect(entityOrError.getValue().isAssociatedWithKeySystem('key-system-identifier')).toBeFalsy()
})
it('should set shared vault association', () => {
const sharedVaultAssociation = SharedVaultAssociation.create({
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
}).getValue()
it('should tell if an item is identical to another item', () => {
const entity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
const entity = Item.create({
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
const otherEntity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
entity.setSharedVaultAssociation(sharedVaultAssociation)
expect(entity.props.sharedVaultAssociation).toEqual(sharedVaultAssociation)
expect(entity.getChanges()).toHaveLength(1)
expect(entity.isIdenticalTo(otherEntity)).toBeTruthy()
})
it('should unset a shared vault association', () => {
const entity = Item.create({
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
sharedVaultAssociation: SharedVaultAssociation.create({
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
}).getValue(),
}).getValue()
it('should tell that an item is not identical to another item', () => {
const entity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
entity.unsetSharedVaultAssociation()
const otherEntity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(124)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
expect(entity.props.sharedVaultAssociation).toBeUndefined()
expect(entity.getChanges()).toHaveLength(1)
expect(entity.isIdenticalTo(otherEntity)).toBeFalsy()
})
it('should set key system association', () => {
const keySystemAssociation = KeySystemAssociation.create({
keySystemIdentifier: 'key-system-identifier',
}).getValue()
it('should tell that an item is not identical to another item if their ids are different', () => {
const entity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
const entity = Item.create({
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
}).getValue()
const otherEntity = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(124)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
entity.setKeySystemAssociation(keySystemAssociation)
expect(entity.props.keySystemAssociation).toEqual(keySystemAssociation)
expect(entity.getChanges()).toHaveLength(1)
})
it('should unset a key system association', () => {
const entity = Item.create({
duplicateOf: null,
itemsKeyId: 'items-key-id',
content: 'content',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key',
authHash: 'auth-hash',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
keySystemAssociation: KeySystemAssociation.create({
keySystemIdentifier: 'key-system-identifier',
}).getValue(),
}).getValue()
entity.unsetKeySystemAssociation()
expect(entity.props.keySystemAssociation).toBeUndefined()
expect(entity.getChanges()).toHaveLength(1)
expect(entity.isIdenticalTo(otherEntity)).toBeFalsy()
})
})
@@ -1,8 +1,6 @@
import { Aggregate, Change, Result, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { Aggregate, Result, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
import { ItemProps } from './ItemProps'
import { SharedVaultAssociation } from '../SharedVault/SharedVaultAssociation'
import { KeySystemAssociation } from '../KeySystem/KeySystemAssociation'
export class Item extends Aggregate<ItemProps> {
private constructor(props: ItemProps, id?: UniqueEntityId) {
@@ -55,57 +53,17 @@ export class Item extends Aggregate<ItemProps> {
return this.props.keySystemAssociation.props.keySystemIdentifier === keySystemIdentifier
}
setSharedVaultAssociation(sharedVaultAssociation: SharedVaultAssociation): void {
this.addChange(
Change.create({
aggregateRootUuid: this.uuid.value,
changeType: this.props.sharedVaultAssociation ? Change.TYPES.Modify : Change.TYPES.Add,
changeData: sharedVaultAssociation,
}).getValue(),
)
this.props.sharedVaultAssociation = sharedVaultAssociation
}
unsetSharedVaultAssociation(): void {
if (!this.props.sharedVaultAssociation) {
return
isIdenticalTo(item: Item): boolean {
if (this._id.toString() !== item._id.toString()) {
return false
}
this.addChange(
Change.create({
aggregateRootUuid: this.uuid.value,
changeType: Change.TYPES.Remove,
changeData: this.props.sharedVaultAssociation,
}).getValue(),
)
this.props.sharedVaultAssociation = undefined
}
const stringifiedThis = JSON.stringify(this.props)
const stringifiedItem = JSON.stringify(item.props)
setKeySystemAssociation(keySystemAssociation: KeySystemAssociation): void {
this.addChange(
Change.create({
aggregateRootUuid: this.uuid.value,
changeType: this.props.keySystemAssociation ? Change.TYPES.Modify : Change.TYPES.Add,
changeData: keySystemAssociation,
}).getValue(),
)
const base64This = Buffer.from(stringifiedThis).toString('base64')
const base64Item = Buffer.from(stringifiedItem).toString('base64')
this.props.keySystemAssociation = keySystemAssociation
}
unsetKeySystemAssociation(): void {
if (!this.props.keySystemAssociation) {
return
}
this.addChange(
Change.create({
aggregateRootUuid: this.uuid.value,
changeType: Change.TYPES.Remove,
changeData: this.props.keySystemAssociation,
}).getValue(),
)
this.props.keySystemAssociation = undefined
return base64This === base64Item
}
}
@@ -1,12 +1,15 @@
import { KeySystemAssociation } from './KeySystemAssociation'
describe('KeySystemAssociation', () => {
it('should create an entity', () => {
const entityOrError = KeySystemAssociation.create({
keySystemIdentifier: '00000000-0000-0000-0000-000000000000',
})
it('should create a value object', () => {
const entityOrError = KeySystemAssociation.create('00000000-0000-0000-0000-000000000000')
expect(entityOrError.isFailed()).toBeFalsy()
expect(entityOrError.getValue().id).not.toBeNull()
})
it('should fail to create a value object with an empty key system identifier', () => {
const entityOrError = KeySystemAssociation.create('')
expect(entityOrError.isFailed()).toBeTruthy()
})
})
@@ -1,13 +1,18 @@
import { Entity, Result, UniqueEntityId } from '@standardnotes/domain-core'
import { Result, Validator, ValueObject } from '@standardnotes/domain-core'
import { KeySystemAssociationProps } from './KeySystemAssocationProps'
export class KeySystemAssociation extends Entity<KeySystemAssociationProps> {
private constructor(props: KeySystemAssociationProps, id?: UniqueEntityId) {
super(props, id)
export class KeySystemAssociation extends ValueObject<KeySystemAssociationProps> {
private constructor(props: KeySystemAssociationProps) {
super(props)
}
static create(props: KeySystemAssociationProps, id?: UniqueEntityId): Result<KeySystemAssociation> {
return Result.ok<KeySystemAssociation>(new KeySystemAssociation(props, id))
static create(keySystemIdentifier: string): Result<KeySystemAssociation> {
const validationResult = Validator.isNotEmptyString(keySystemIdentifier)
if (validationResult.isFailed()) {
return Result.fail<KeySystemAssociation>(validationResult.getError())
}
return Result.ok<KeySystemAssociation>(new KeySystemAssociation({ keySystemIdentifier }))
}
}
@@ -5,7 +5,6 @@ import { SharedVault } from './SharedVault'
describe('SharedVault', () => {
it('should create an entity', () => {
const entityOrError = SharedVault.create({
fileUploadBytesLimit: 1_000_000,
fileUploadBytesUsed: 0,
timestamps: Timestamps.create(123456789, 123456789).getValue(),
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
@@ -19,7 +18,6 @@ describe('SharedVault', () => {
it('should throw an error if id cannot be cast to uuid', () => {
const entityOrError = SharedVault.create(
{
fileUploadBytesLimit: 1_000_000,
fileUploadBytesUsed: 0,
timestamps: Timestamps.create(123456789, 123456789).getValue(),
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
@@ -3,13 +3,12 @@ import { Uuid } from '@standardnotes/domain-core'
import { SharedVaultAssociation } from './SharedVaultAssociation'
describe('SharedVaultAssociation', () => {
it('should create an entity', () => {
it('should create a value object', () => {
const entityOrError = SharedVaultAssociation.create({
lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
})
expect(entityOrError.isFailed()).toBeFalsy()
expect(entityOrError.getValue().id).not.toBeNull()
})
})
@@ -1,13 +1,13 @@
import { Aggregate, Result, UniqueEntityId } from '@standardnotes/domain-core'
import { Result, ValueObject } from '@standardnotes/domain-core'
import { SharedVaultAssociationProps } from './SharedVaultAssociationProps'
export class SharedVaultAssociation extends Aggregate<SharedVaultAssociationProps> {
private constructor(props: SharedVaultAssociationProps, id?: UniqueEntityId) {
super(props, id)
export class SharedVaultAssociation extends ValueObject<SharedVaultAssociationProps> {
private constructor(props: SharedVaultAssociationProps) {
super(props)
}
static create(props: SharedVaultAssociationProps, id?: UniqueEntityId): Result<SharedVaultAssociation> {
return Result.ok<SharedVaultAssociation>(new SharedVaultAssociation(props, id))
static create(props: SharedVaultAssociationProps): Result<SharedVaultAssociation> {
return Result.ok<SharedVaultAssociation>(new SharedVaultAssociation(props))
}
}
@@ -3,6 +3,5 @@ import { Uuid, Timestamps } from '@standardnotes/domain-core'
export interface SharedVaultProps {
userUuid: Uuid
fileUploadBytesUsed: number
fileUploadBytesLimit: number
timestamps: Timestamps
}
@@ -7,16 +7,15 @@ import {
Uuid,
Validator,
} from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { CreateSharedVaultResult } from './CreateSharedVaultResult'
import { CreateSharedVaultDTO } from './CreateSharedVaultDTO'
import { TimerInterface } from '@standardnotes/time'
import { SharedVaultRepositoryInterface } from '../../../SharedVault/SharedVaultRepositoryInterface'
import { AddUserToSharedVault } from '../AddUserToSharedVault/AddUserToSharedVault'
import { SharedVault } from '../../../SharedVault/SharedVault'
export class CreateSharedVault implements UseCaseInterface<CreateSharedVaultResult> {
private readonly FILE_UPLOAD_BYTES_LIMIT = 1_000_000_000
constructor(
private addUserToSharedVault: AddUserToSharedVault,
private sharedVaultRepository: SharedVaultRepositoryInterface,
@@ -49,7 +48,6 @@ export class CreateSharedVault implements UseCaseInterface<CreateSharedVaultResu
).getValue()
const sharedVaultOrError = SharedVault.create({
fileUploadBytesLimit: this.FILE_UPLOAD_BYTES_LIMIT,
fileUploadBytesUsed: 0,
userUuid,
timestamps,
@@ -19,7 +19,6 @@ describe('CreateSharedVaultFileValetToken', () => {
beforeEach(() => {
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -89,7 +89,7 @@ export class CreateSharedVaultFileValetToken implements UseCaseInterface<string>
permittedOperation: dto.operation,
remoteIdentifier: dto.remoteIdentifier,
uploadBytesUsed: sharedVault.props.fileUploadBytesUsed,
uploadBytesLimit: sharedVault.props.fileUploadBytesLimit,
uploadBytesLimit: dto.sharedVaultOwnerUploadBytesLimit,
unencryptedFileSize: dto.unencryptedFileSize,
moveOperation: this.createMoveOperationData(dto),
}
@@ -3,6 +3,7 @@ import { SharedVaultMoveType, ValetTokenOperation } from '@standardnotes/securit
export interface CreateSharedVaultFileValetTokenDTO {
userUuid: string
sharedVaultUuid: string
sharedVaultOwnerUploadBytesLimit?: number
fileUuid?: string
remoteIdentifier: string
operation: ValetTokenOperation
@@ -26,7 +26,6 @@ describe('DeleteSharedVault', () => {
beforeEach(() => {
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -110,7 +109,6 @@ describe('DeleteSharedVault', () => {
it('should return error when originator of the delete request is not the owner of the shared vault', async () => {
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -15,7 +15,6 @@ describe('GetSharedVaultUsers', () => {
beforeEach(() => {
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -61,7 +60,6 @@ describe('GetSharedVaultUsers', () => {
it('returns error when originator is not the owner of the shared vault', async () => {
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000001').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -26,7 +26,6 @@ describe('GetSharedVaults', () => {
sharedVault = SharedVault.create({
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
fileUploadBytesLimit: 123,
fileUploadBytesUsed: 123,
}).getValue()
sharedVaultRepository = {} as jest.Mocked<SharedVaultRepositoryInterface>
@@ -22,7 +22,6 @@ describe('InviteUserToSharedVault', () => {
beforeEach(() => {
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -179,7 +178,6 @@ describe('InviteUserToSharedVault', () => {
const useCase = createUseCase()
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('10000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -19,7 +19,6 @@ describe('RemoveUserFromSharedVault', () => {
beforeEach(() => {
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -84,7 +83,6 @@ describe('RemoveUserFromSharedVault', () => {
it('should return error when user is not owner of shared vault', async () => {
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000002').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -104,7 +102,6 @@ describe('RemoveUserFromSharedVault', () => {
it('should remove shared vault user if user is owner and is being force removed', async () => {
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000002').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -11,7 +11,6 @@ describe('UpdateStorageQuotaUsedInSharedVault', () => {
beforeEach(() => {
sharedVault = SharedVault.create({
fileUploadBytesLimit: 100,
fileUploadBytesUsed: 2,
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
@@ -123,7 +123,7 @@ export class SaveNewItem implements UseCaseInterface<Item> {
if (sharedVaultAssociationOrError.isFailed()) {
return Result.fail(sharedVaultAssociationOrError.getError())
}
newItem.setSharedVaultAssociation(sharedVaultAssociationOrError.getValue())
newItem.props.sharedVaultAssociation = sharedVaultAssociationOrError.getValue()
}
if (dto.itemHash.hasDedicatedKeySystemAssociation()) {
@@ -133,13 +133,11 @@ export class SaveNewItem implements UseCaseInterface<Item> {
}
const keySystemIdentifier = dto.itemHash.props.key_system_identifier as string
const keySystemAssociationOrError = KeySystemAssociation.create({
keySystemIdentifier,
})
const keySystemAssociationOrError = KeySystemAssociation.create(keySystemIdentifier)
if (keySystemAssociationOrError.isFailed()) {
return Result.fail(keySystemAssociationOrError.getError())
}
newItem.setKeySystemAssociation(keySystemAssociationOrError.getValue())
newItem.props.keySystemAssociation = keySystemAssociationOrError.getValue()
}
const itemRepository = this.itemRepositoryResolver.resolve(roleNames)
@@ -382,13 +382,10 @@ describe('UpdateExistingItem', () => {
shared_vault_uuid: '00000000-0000-0000-0000-000000000000',
}).getValue()
item1.setSharedVaultAssociation(
SharedVaultAssociation.create({
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
}).getValue(),
)
const idBefore = item1.props.sharedVaultAssociation?.id.toString()
item1.props.sharedVaultAssociation = SharedVaultAssociation.create({
sharedVaultUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
lastEditedBy: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
}).getValue()
const result = await useCase.execute({
existingItem: item1,
@@ -401,7 +398,6 @@ describe('UpdateExistingItem', () => {
expect(result.isFailed()).toBeFalsy()
expect(item1.props.sharedVaultAssociation).not.toBeUndefined()
expect((item1.props.sharedVaultAssociation as SharedVaultAssociation).id.toString()).toEqual(idBefore)
})
it('should return error if shared vault association could not be created', async () => {
@@ -567,12 +563,7 @@ describe('UpdateExistingItem', () => {
key_system_identifier: '00000000-0000-0000-0000-000000000000',
}).getValue()
item1.setKeySystemAssociation(
KeySystemAssociation.create({
keySystemIdentifier: '00000000-0000-0000-0000-000000000000',
}).getValue(),
)
const idBefore = item1.props.keySystemAssociation?.id.toString()
item1.props.keySystemAssociation = KeySystemAssociation.create('00000000-0000-0000-0000-000000000000').getValue()
const result = await useCase.execute({
existingItem: item1,
@@ -585,7 +576,6 @@ describe('UpdateExistingItem', () => {
expect(result.isFailed()).toBeFalsy()
expect(item1.props.keySystemAssociation).not.toBeUndefined()
expect((item1.props.keySystemAssociation as KeySystemAssociation).id.toString()).toEqual(idBefore)
})
it('should return error if key system identifier is invalid', async () => {
@@ -6,7 +6,6 @@ import {
Result,
RoleNameCollection,
Timestamps,
UniqueEntityId,
UseCaseInterface,
Uuid,
Validator,
@@ -131,23 +130,16 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
let sharedVaultOperation: SharedVaultOperationOnItem | null = null
if (dto.itemHash.representsASharedVaultItem()) {
const sharedVaultAssociationOrError = SharedVaultAssociation.create(
{
lastEditedBy: userUuid,
sharedVaultUuid: dto.itemHash.sharedVaultUuid as Uuid,
},
new UniqueEntityId(
dto.existingItem.props.sharedVaultAssociation
? dto.existingItem.props.sharedVaultAssociation.id.toString()
: undefined,
),
)
const sharedVaultAssociationOrError = SharedVaultAssociation.create({
lastEditedBy: userUuid,
sharedVaultUuid: dto.itemHash.sharedVaultUuid as Uuid,
})
if (sharedVaultAssociationOrError.isFailed()) {
return Result.fail(sharedVaultAssociationOrError.getError())
}
dto.existingItem.setSharedVaultAssociation(sharedVaultAssociationOrError.getValue())
dto.existingItem.props.sharedVaultAssociation = sharedVaultAssociationOrError.getValue()
const sharedVaultOperationOrError = await this.determineSharedVaultOperationOnItem.execute({
existingItem: dto.existingItem,
@@ -159,7 +151,7 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
}
sharedVaultOperation = sharedVaultOperationOrError.getValue()
} else {
dto.existingItem.unsetSharedVaultAssociation()
dto.existingItem.props.sharedVaultAssociation = undefined
}
if (dto.itemHash.hasDedicatedKeySystemAssociation()) {
@@ -169,23 +161,14 @@ export class UpdateExistingItem implements UseCaseInterface<Item> {
}
const keySystemIdentifier = dto.itemHash.props.key_system_identifier as string
const keySystemAssociationOrError = KeySystemAssociation.create(
{
keySystemIdentifier,
},
new UniqueEntityId(
dto.existingItem.props.keySystemAssociation
? dto.existingItem.props.keySystemAssociation.id.toString()
: undefined,
),
)
const keySystemAssociationOrError = KeySystemAssociation.create(keySystemIdentifier)
if (keySystemAssociationOrError.isFailed()) {
return Result.fail(keySystemAssociationOrError.getError())
}
dto.existingItem.setKeySystemAssociation(keySystemAssociationOrError.getValue())
dto.existingItem.props.keySystemAssociation = keySystemAssociationOrError.getValue()
} else {
dto.existingItem.unsetKeySystemAssociation()
dto.existingItem.props.keySystemAssociation = undefined
}
if (dto.itemHash.props.deleted === true) {
@@ -0,0 +1,358 @@
import { Logger } from 'winston'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { TransitionItemsFromPrimaryToSecondaryDatabaseForUser } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUser'
import { Item } from '../../../Item/Item'
import { ContentType, Dates, Timestamps, UniqueEntityId, Uuid } from '@standardnotes/domain-core'
describe('TransitionItemsFromPrimaryToSecondaryDatabaseForUser', () => {
let primaryItemRepository: ItemRepositoryInterface
let secondaryItemRepository: ItemRepositoryInterface | null
let logger: Logger
let primaryItem1: Item
let primaryItem2: Item
let secondaryItem1: Item
let secondaryItem2: Item
const createUseCase = () =>
new TransitionItemsFromPrimaryToSecondaryDatabaseForUser(primaryItemRepository, secondaryItemRepository, logger)
beforeEach(() => {
primaryItem1 = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id=1',
content: 'content-1',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key-1',
authHash: 'auth-hash-1',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
primaryItem2 = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id=2',
content: 'content-2',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key-2',
authHash: 'auth-hash-2',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: true,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
secondaryItem1 = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id=1',
content: 'content-1',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key-1',
authHash: 'auth-hash-1',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: false,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000000'),
).getValue()
secondaryItem2 = Item.create(
{
duplicateOf: null,
itemsKeyId: 'items-key-id=2',
content: 'content-2',
contentType: ContentType.create(ContentType.TYPES.Note).getValue(),
encItemKey: 'enc-item-key-2',
authHash: 'auth-hash-2',
userUuid: Uuid.create('00000000-0000-0000-0000-000000000000').getValue(),
deleted: true,
updatedWithSession: null,
dates: Dates.create(new Date(123), new Date(123)).getValue(),
timestamps: Timestamps.create(123, 123).getValue(),
},
new UniqueEntityId('00000000-0000-0000-0000-000000000001'),
).getValue()
primaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
primaryItemRepository.countAll = jest.fn().mockResolvedValue(2)
primaryItemRepository.findAll = jest
.fn()
.mockResolvedValueOnce([primaryItem1])
.mockResolvedValueOnce([primaryItem2])
.mockResolvedValueOnce([primaryItem1])
.mockResolvedValueOnce([primaryItem2])
primaryItemRepository.deleteByUserUuid = jest.fn().mockResolvedValue(undefined)
secondaryItemRepository = {} as jest.Mocked<ItemRepositoryInterface>
secondaryItemRepository.save = jest.fn().mockResolvedValue(undefined)
secondaryItemRepository.deleteByUserUuid = jest.fn().mockResolvedValue(undefined)
secondaryItemRepository.countAll = jest.fn().mockResolvedValue(2)
secondaryItemRepository.findByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryItem1)
.mockResolvedValueOnce(secondaryItem2)
logger = {} as jest.Mocked<Logger>
logger.error = jest.fn()
})
describe('successfull transition', () => {
it('should transition items from primary to secondary database', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(primaryItemRepository.countAll).toHaveBeenCalledTimes(2)
expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect(primaryItemRepository.findAll).toHaveBeenCalledTimes(4)
expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(1, {
userUuid: '00000000-0000-0000-0000-000000000000',
limit: 1,
offset: 0,
})
expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(2, {
userUuid: '00000000-0000-0000-0000-000000000000',
limit: 1,
offset: 1,
})
expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(3, {
userUuid: '00000000-0000-0000-0000-000000000000',
limit: 1,
offset: 0,
})
expect(primaryItemRepository.findAll).toHaveBeenNthCalledWith(4, {
userUuid: '00000000-0000-0000-0000-000000000000',
limit: 1,
offset: 1,
})
expect((secondaryItemRepository as ItemRepositoryInterface).save).toHaveBeenCalledTimes(2)
expect((secondaryItemRepository as ItemRepositoryInterface).save).toHaveBeenCalledWith(primaryItem1)
expect((secondaryItemRepository as ItemRepositoryInterface).save).toHaveBeenCalledWith(primaryItem2)
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).not.toHaveBeenCalled()
expect(primaryItemRepository.deleteByUserUuid).toHaveBeenCalledTimes(1)
})
it('should log an error if deleting items from primary database fails', async () => {
primaryItemRepository.deleteByUserUuid = jest.fn().mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeFalsy()
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith(
'Failed to clean up primary database items for user 00000000-0000-0000-0000-000000000000: error',
)
})
})
describe('failed transition', () => {
it('should remove items from secondary database if integrity check fails', async () => {
const secondaryItem2WithDifferentContent = Item.create({
...secondaryItem2.props,
content: 'different-content',
}).getValue()
;(secondaryItemRepository as ItemRepositoryInterface).findByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryItem1)
.mockResolvedValueOnce(secondaryItem2WithDifferentContent)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Item 00000000-0000-0000-0000-000000000001 is not identical in primary and secondary database',
)
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
it('should remove items from secondary database if migrating items fails', async () => {
primaryItemRepository.findAll = jest
.fn()
.mockResolvedValueOnce([primaryItem1])
.mockRejectedValueOnce(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('error')
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
it('should log an error if deleting items from secondary database fails upon migration failure', async () => {
primaryItemRepository.findAll = jest
.fn()
.mockResolvedValueOnce([primaryItem1])
.mockRejectedValueOnce(new Error('error'))
;(secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid = jest
.fn()
.mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith(
'Failed to clean up secondary database items for user 00000000-0000-0000-0000-000000000000: error',
)
})
it('should log an error if deleting items from secondary database fails upon integrity check failure', async () => {
const secondaryItem2WithDifferentContent = Item.create({
...secondaryItem2.props,
content: 'different-content',
}).getValue()
;(secondaryItemRepository as ItemRepositoryInterface).findByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryItem1)
.mockResolvedValueOnce(secondaryItem2WithDifferentContent)
;(secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid = jest
.fn()
.mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(logger.error).toHaveBeenCalledTimes(1)
expect(logger.error).toHaveBeenCalledWith(
'Failed to clean up secondary database items for user 00000000-0000-0000-0000-000000000000: error',
)
})
it('should not perform the transition if secondary item repository is not set', async () => {
secondaryItemRepository = null
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Secondary item repository is not set')
expect(primaryItemRepository.countAll).not.toHaveBeenCalled()
expect(primaryItemRepository.findAll).not.toHaveBeenCalled()
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
it('should not perform the transition if the user uuid is invalid', async () => {
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: 'invalid-uuid',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Given value is not a valid uuid: invalid-uuid')
expect(primaryItemRepository.countAll).not.toHaveBeenCalled()
expect(primaryItemRepository.findAll).not.toHaveBeenCalled()
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
})
it('should fail integrity check if the item count is not the same in both databases', async () => {
;(secondaryItemRepository as ItemRepositoryInterface).countAll = jest.fn().mockResolvedValue(1)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual(
'Total items count for user 00000000-0000-0000-0000-000000000000 in primary database (2) does not match total items count in secondary database (1)',
)
expect(primaryItemRepository.countAll).toHaveBeenCalledTimes(2)
expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect((secondaryItemRepository as ItemRepositoryInterface).countAll).toHaveBeenCalledTimes(1)
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
})
it('should fail if one item is not found in the secondary database', async () => {
;(secondaryItemRepository as ItemRepositoryInterface).findByUuid = jest
.fn()
.mockResolvedValueOnce(secondaryItem1)
.mockResolvedValueOnce(null)
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('Item 00000000-0000-0000-0000-000000000001 not found in secondary database')
expect(primaryItemRepository.countAll).toHaveBeenCalledTimes(2)
expect(primaryItemRepository.countAll).toHaveBeenCalledWith({ userUuid: '00000000-0000-0000-0000-000000000000' })
expect((secondaryItemRepository as ItemRepositoryInterface).countAll).toHaveBeenCalledTimes(1)
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
})
it('should fail if an error is thrown during integrity check between primary and secondary database', async () => {
;(secondaryItemRepository as ItemRepositoryInterface).countAll = jest.fn().mockRejectedValue(new Error('error'))
const useCase = createUseCase()
const result = await useCase.execute({
userUuid: '00000000-0000-0000-0000-000000000000',
})
expect(result.isFailed()).toBeTruthy()
expect(result.getError()).toEqual('error')
expect(primaryItemRepository.deleteByUserUuid).not.toHaveBeenCalled()
expect((secondaryItemRepository as ItemRepositoryInterface).deleteByUserUuid).toHaveBeenCalledTimes(1)
})
})
})
@@ -0,0 +1,140 @@
import { Result, UseCaseInterface, Uuid } from '@standardnotes/domain-core'
import { Logger } from 'winston'
import { TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO } from './TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO'
import { ItemRepositoryInterface } from '../../../Item/ItemRepositoryInterface'
import { ItemQuery } from '../../../Item/ItemQuery'
export class TransitionItemsFromPrimaryToSecondaryDatabaseForUser implements UseCaseInterface<void> {
constructor(
private primaryItemRepository: ItemRepositoryInterface,
private secondaryItemRepository: ItemRepositoryInterface | null,
private logger: Logger,
) {}
async execute(dto: TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO): Promise<Result<void>> {
if (this.secondaryItemRepository === null) {
return Result.fail('Secondary item repository is not set')
}
const userUuidOrError = Uuid.create(dto.userUuid)
if (userUuidOrError.isFailed()) {
return Result.fail(userUuidOrError.getError())
}
const userUuid = userUuidOrError.getValue()
const migrationResult = await this.migrateItemsForUser(userUuid)
if (migrationResult.isFailed()) {
const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up secondary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
}
return Result.fail(migrationResult.getError())
}
const integrityCheckResult = await this.checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid)
if (integrityCheckResult.isFailed()) {
const cleanupResult = await this.deleteItemsForUser(userUuid, this.secondaryItemRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up secondary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
}
return Result.fail(integrityCheckResult.getError())
}
const cleanupResult = await this.deleteItemsForUser(userUuid, this.primaryItemRepository)
if (cleanupResult.isFailed()) {
this.logger.error(
`Failed to clean up primary database items for user ${userUuid.value}: ${cleanupResult.getError()}`,
)
}
return Result.ok()
}
private async migrateItemsForUser(userUuid: Uuid): Promise<Result<void>> {
try {
const totalItemsCountForUser = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
const pageSize = 1
const totalPages = totalItemsCountForUser
let currentPage = 1
for (currentPage; currentPage <= totalPages; currentPage++) {
const query: ItemQuery = {
userUuid: userUuid.value,
offset: currentPage - 1,
limit: pageSize,
}
const items = await this.primaryItemRepository.findAll(query)
for (const item of items) {
await (this.secondaryItemRepository as ItemRepositoryInterface).save(item)
}
}
return Result.ok()
} catch (error) {
return Result.fail((error as Error).message)
}
}
private async deleteItemsForUser(userUuid: Uuid, itemRepository: ItemRepositoryInterface): Promise<Result<void>> {
try {
await itemRepository.deleteByUserUuid(userUuid.value)
return Result.ok()
} catch (error) {
return Result.fail((error as Error).message)
}
}
private async checkIntegrityBetweenPrimaryAndSecondaryDatabase(userUuid: Uuid): Promise<Result<boolean>> {
try {
const totalItemsCountForUserInPrimary = await this.primaryItemRepository.countAll({ userUuid: userUuid.value })
const totalItemsCountForUserInSecondary = await (
this.secondaryItemRepository as ItemRepositoryInterface
).countAll({
userUuid: userUuid.value,
})
if (totalItemsCountForUserInPrimary !== totalItemsCountForUserInSecondary) {
return Result.fail(
`Total items count for user ${userUuid.value} in primary database (${totalItemsCountForUserInPrimary}) does not match total items count in secondary database (${totalItemsCountForUserInSecondary})`,
)
}
const pageSize = 1
const totalPages = totalItemsCountForUserInPrimary
let currentPage = 1
for (currentPage; currentPage <= totalPages; currentPage++) {
const query: ItemQuery = {
userUuid: userUuid.value,
offset: currentPage - 1,
limit: pageSize,
}
const items = await this.primaryItemRepository.findAll(query)
for (const item of items) {
const itemInSecondary = await (this.secondaryItemRepository as ItemRepositoryInterface).findByUuid(item.uuid)
if (!itemInSecondary) {
return Result.fail(`Item ${item.uuid.value} not found in secondary database`)
}
if (!item.isIdenticalTo(itemInSecondary)) {
return Result.fail(`Item ${item.uuid.value} is not identical in primary and secondary database`)
}
}
}
return Result.ok()
} catch (error) {
return Result.fail((error as Error).message)
}
}
}
@@ -0,0 +1,3 @@
export interface TransitionItemsFromPrimaryToSecondaryDatabaseForUserDTO {
userUuid: string
}
@@ -104,6 +104,7 @@ export class BaseSharedVaultsController extends BaseHttpController {
const result = await this.createSharedVaultFileValetTokenUseCase.execute({
userUuid: response.locals.user.uuid,
sharedVaultUuid: request.params.sharedVaultUuid,
sharedVaultOwnerUploadBytesLimit: response.locals.sharedVaultOwnerContext?.upload_bytes_limit,
fileUuid: request.body.file_uuid,
remoteIdentifier: request.body.remote_identifier,
operation: request.body.operation,
@@ -25,6 +25,7 @@ export class InversifyExpressAuthMiddleware extends BaseMiddleware {
response.locals.roles = decodedToken.roles
response.locals.session = decodedToken.session
response.locals.readOnlyAccess = decodedToken.session?.readonly_access ?? false
response.locals.sharedVaultOwnerContext = decodedToken.shared_vault_owner_context
return next()
} catch (error) {
@@ -1,33 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'key_system_associations' })
export class TypeORMKeySystemAssociation {
@PrimaryGeneratedColumn('uuid')
declare uuid: string
@Column({
name: 'key_system_identifier',
length: 36,
})
@Index('key_system_identifier_on_key_system_associations')
declare keySystemIdentifier: string
@Column({
name: 'item_uuid',
length: 36,
})
@Index('item_uuid_on_key_system_associations')
declare itemUuid: string
@Column({
name: 'created_at_timestamp',
type: 'bigint',
})
declare createdAtTimestamp: number
@Column({
name: 'updated_at_timestamp',
type: 'bigint',
})
declare updatedAtTimestamp: number
}
@@ -18,12 +18,6 @@ export class TypeORMSharedVault {
})
declare fileUploadBytesUsed: number
@Column({
name: 'file_upload_bytes_limit',
type: 'int',
})
declare fileUploadBytesLimit: number
@Column({
name: 'created_at_timestamp',
type: 'bigint',
@@ -1,40 +0,0 @@
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'
@Entity({ name: 'shared_vault_associations' })
export class TypeORMSharedVaultAssociation {
@PrimaryGeneratedColumn('uuid')
declare uuid: string
@Column({
name: 'shared_vault_uuid',
length: 36,
})
@Index('shared_vault_uuid_on_shared_vault_associations')
declare sharedVaultUuid: string
@Column({
name: 'item_uuid',
length: 36,
})
@Index('item_uuid_on_shared_vault_associations')
declare itemUuid: string
@Column({
name: 'last_edited_by',
type: 'varchar',
length: 36,
})
declare lastEditedBy: string
@Column({
name: 'created_at_timestamp',
type: 'bigint',
})
declare createdAtTimestamp: number
@Column({
name: 'updated_at_timestamp',
type: 'bigint',
})
declare updatedAtTimestamp: number
}
@@ -12,7 +12,6 @@ export class SharedVaultHttpMapper implements MapperInterface<SharedVault, Share
return {
uuid: domain.id.toString(),
user_uuid: domain.props.userUuid.value,
file_upload_bytes_limit: domain.props.fileUploadBytesLimit,
file_upload_bytes_used: domain.props.fileUploadBytesUsed,
created_at_timestamp: domain.props.timestamps.createdAt,
updated_at_timestamp: domain.props.timestamps.updatedAt,
@@ -2,7 +2,6 @@ export interface SharedVaultHttpRepresentation {
uuid: string
user_uuid: string
file_upload_bytes_used: number
file_upload_bytes_limit: number
created_at_timestamp: number
updated_at_timestamp: number
}
@@ -73,9 +73,7 @@ export class MongoDBItemPersistenceMapper implements MapperInterface<Item, Mongo
let keySystemAssociation: KeySystemAssociation | undefined = undefined
if (projection.keySystemIdentifier) {
const keySystemAssociationOrError = KeySystemAssociation.create({
keySystemIdentifier: projection.keySystemIdentifier,
})
const keySystemAssociationOrError = KeySystemAssociation.create(projection.keySystemIdentifier)
if (keySystemAssociationOrError.isFailed()) {
throw new Error(`Failed to create item from projection: ${keySystemAssociationOrError.getError()}`)
}
@@ -21,7 +21,6 @@ export class SharedVaultPersistenceMapper implements MapperInterface<SharedVault
{
userUuid,
fileUploadBytesUsed: projection.fileUploadBytesUsed,
fileUploadBytesLimit: projection.fileUploadBytesLimit,
timestamps,
},
new UniqueEntityId(projection.uuid),
@@ -40,7 +39,6 @@ export class SharedVaultPersistenceMapper implements MapperInterface<SharedVault
typeorm.uuid = domain.id.toString()
typeorm.userUuid = domain.props.userUuid.value
typeorm.fileUploadBytesUsed = domain.props.fileUploadBytesUsed
typeorm.fileUploadBytesLimit = domain.props.fileUploadBytesLimit
typeorm.createdAtTimestamp = domain.props.timestamps.createdAt
typeorm.updatedAtTimestamp = domain.props.timestamps.updatedAt
+4
View File
@@ -3,6 +3,10 @@
All notable changes to this project will be documented in this file.
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
## [1.10.19](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.10.18...@standardnotes/websockets-server@1.10.19) (2023-08-22)
**Note:** Version bump only for package @standardnotes/websockets-server
## [1.10.18](https://github.com/standardnotes/server/compare/@standardnotes/websockets-server@1.10.17...@standardnotes/websockets-server@1.10.18) (2023-08-18)
**Note:** Version bump only for package @standardnotes/websockets-server
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@standardnotes/websockets-server",
"version": "1.10.18",
"version": "1.10.19",
"engines": {
"node": ">=18.0.0 <21.0.0"
},