fix: Fixes ephemeral session revocation

This commit is contained in:
Antonella Sgarlatta
2026-09-08 15:59:34 -03:00
parent d07c91594a
commit f1ea94e571
4 changed files with 129 additions and 0 deletions
@@ -0,0 +1,60 @@
import * as IORedis from 'ioredis'
import { EphemeralSession } from '../../Domain/Session/EphemeralSession'
import { RedisEphemeralSessionRepository } from './RedisEphemeralSessionRepository'
describe('RedisEphemeralSessionRepository', () => {
let redisClient: jest.Mocked<IORedis.Redis>
let pipeline: {
del: jest.Mock
srem: jest.Mock
exec: jest.Mock
}
let repository: RedisEphemeralSessionRepository
beforeEach(() => {
pipeline = {
del: jest.fn().mockReturnThis(),
srem: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue([]),
}
redisClient = {
get: jest.fn(),
pipeline: jest.fn().mockReturnValue(pipeline),
} as unknown as jest.Mocked<IORedis.Redis>
repository = new RedisEphemeralSessionRepository(redisClient, 3600)
})
describe('deleteOne', () => {
it('should delete private identifier mapping when revoking a session', async () => {
const session = {
uuid: 'session-uuid',
userUuid: 'user-uuid',
privateIdentifier: 'private-id',
} as EphemeralSession
redisClient.get.mockResolvedValue(JSON.stringify(session))
await repository.deleteOne('session-uuid', 'user-uuid')
expect(redisClient.get).toHaveBeenCalledWith('session:session-uuid:user-uuid')
expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid')
expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid:user-uuid')
expect(pipeline.del).toHaveBeenCalledWith('session-private-id:private-id')
expect(pipeline.srem).toHaveBeenCalledWith('user-sessions:user-uuid', 'session-uuid')
expect(pipeline.exec).toHaveBeenCalled()
})
it('should skip private identifier deletion when session is not found', async () => {
redisClient.get.mockResolvedValue(null)
await repository.deleteOne('session-uuid', 'user-uuid')
expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid')
expect(pipeline.del).toHaveBeenCalledWith('session:session-uuid:user-uuid')
expect(pipeline.del).not.toHaveBeenCalledWith(expect.stringContaining('session-private-id'))
})
})
})
@@ -26,12 +26,18 @@ export class RedisEphemeralSessionRepository implements EphemeralSessionReposito
}
async deleteOne(uuid: string, userUuid: string): Promise<void> {
const session = await this.findOneByUuidAndUserUuid(uuid, userUuid)
const pipeline = this.redisClient.pipeline()
pipeline.del(`${this.PREFIX}:${uuid}`)
pipeline.del(`${this.PREFIX}:${uuid}:${userUuid}`)
pipeline.srem(`${this.USER_SESSIONS_PREFIX}:${userUuid}`, uuid)
if (session?.privateIdentifier) {
pipeline.del(`${this.PREFIX_PRIVATE_ID}:${session.privateIdentifier}`)
}
await pipeline.exec()
}
@@ -0,0 +1,57 @@
import { CacheEntry, CacheEntryRepositoryInterface } from '@standardnotes/domain-core'
import { TimerInterface } from '@standardnotes/time'
import { EphemeralSession } from '../../Domain/Session/EphemeralSession'
import { TypeORMEphemeralSessionRepository } from './TypeORMEphemeralSessionRepository'
describe('TypeORMEphemeralSessionRepository', () => {
let cacheEntryRepository: jest.Mocked<CacheEntryRepositoryInterface>
let timer: jest.Mocked<TimerInterface>
let repository: TypeORMEphemeralSessionRepository
beforeEach(() => {
cacheEntryRepository = {
findUnexpiredOneByKey: jest.fn(),
removeByKey: jest.fn(),
save: jest.fn(),
} as unknown as jest.Mocked<CacheEntryRepositoryInterface>
timer = {} as jest.Mocked<TimerInterface>
repository = new TypeORMEphemeralSessionRepository(cacheEntryRepository, 3600, timer)
})
describe('deleteOne', () => {
it('should delete private identifier mapping when revoking a session', async () => {
const session = {
uuid: 'session-uuid',
userUuid: 'user-uuid',
privateIdentifier: 'private-id',
} as EphemeralSession
cacheEntryRepository.findUnexpiredOneByKey.mockResolvedValueOnce(
CacheEntry.create({
key: 'session:session-uuid:user-uuid',
value: JSON.stringify(session),
expiresAt: new Date(),
}).getValue(),
)
await repository.deleteOne('session-uuid', 'user-uuid')
expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid')
expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid:user-uuid')
expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session-private-id:private-id')
})
it('should skip private identifier deletion when session is not found', async () => {
cacheEntryRepository.findUnexpiredOneByKey.mockResolvedValueOnce(null)
await repository.deleteOne('session-uuid', 'user-uuid')
expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid')
expect(cacheEntryRepository.removeByKey).toHaveBeenCalledWith('session:session-uuid:user-uuid')
expect(cacheEntryRepository.removeByKey).not.toHaveBeenCalledWith(expect.stringContaining('session-private-id'))
})
})
})
@@ -27,9 +27,15 @@ export class TypeORMEphemeralSessionRepository implements EphemeralSessionReposi
}
async deleteOne(uuid: string, userUuid: string): Promise<void> {
const session = await this.findOneByUuidAndUserUuid(uuid, userUuid)
await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${uuid}`)
await this.cacheEntryRepository.removeByKey(`${this.PREFIX}:${uuid}:${userUuid}`)
if (session?.privateIdentifier) {
await this.cacheEntryRepository.removeByKey(`${this.PREFIX_PRIVATE_ID}:${session.privateIdentifier}`)
}
const userSessionsJSON = await this.cacheEntryRepository.findUnexpiredOneByKey(
`${this.USER_SESSIONS_PREFIX}:${userUuid}`,
)