fix: harden auth and cache concurrency

This commit is contained in:
2026-08-23 22:42:35 +03:00
parent 71d57d60bc
commit c77b3cf782
6 changed files with 289 additions and 38 deletions
+150
View File
@@ -0,0 +1,150 @@
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { createHash } from 'crypto';
jest.mock('bcrypt', () => ({
hash: jest.fn(() => Promise.resolve('password-hash')),
compare: jest.fn(() => Promise.resolve(true)),
}));
jest.mock('../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { AuthService } from './auth.service';
const user = {
id: 'user',
email: 'user@example.com',
passwordHash: 'password-hash',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
};
function hash(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function twoCallBarrier<T>(value: T): () => Promise<T> {
let calls = 0;
let release: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
return async () => {
calls += 1;
if (calls === 2) release();
await ready;
return value;
};
}
describe('AuthService', () => {
const jwt = new JwtService({ secret: 'test-secret-that-is-long-enough' });
const prisma = {
user: { findUnique: jest.fn(), create: jest.fn() },
session: {
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
updateMany: jest.fn(),
delete: jest.fn(),
},
};
const service = new AuthService(prisma as never, jwt);
beforeEach(() => {
jest.clearAllMocks();
});
it('maps the database loser of a concurrent registration to EMAIL_ALREADY_REGISTERED', async () => {
prisma.user.findUnique.mockImplementation(twoCallBarrier(null));
prisma.user.create
.mockResolvedValueOnce(user)
.mockRejectedValueOnce(
Object.assign(new Error('duplicate email'), { code: 'P2002' }),
);
prisma.session.create.mockResolvedValue({
id: 'session',
userId: user.id,
refreshTokenHash: '',
expiresAt: new Date(),
revokedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
});
prisma.session.update.mockResolvedValue({});
const results = await Promise.allSettled([
service.register(user.email, 'secure password'),
service.register(user.email, 'secure password'),
]);
const rejected = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1);
expect(rejected?.reason).toEqual(expect.any(ConflictException));
expect((rejected?.reason as ConflictException).getResponse()).toEqual({
code: 'EMAIL_ALREADY_REGISTERED',
});
});
it('allows one concurrent refresh winner without revoking its session', async () => {
const oldToken = await jwt.signAsync(
{ sub: user.id, sid: 'session', type: 'refresh', jti: 'old-token' },
{ expiresIn: '7d', algorithm: 'HS256' },
);
let currentHash = hash(oldToken);
const session = {
id: 'session',
userId: user.id,
refreshTokenHash: currentHash,
expiresAt: new Date(Date.now() + 60_000),
revokedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
user,
};
prisma.session.findUnique.mockImplementation(twoCallBarrier(session));
prisma.session.updateMany.mockImplementation(
({
where,
data,
}: {
where: { id?: string; refreshTokenHash?: string };
data: { refreshTokenHash: string };
}) => {
if (where.id === session.id && where.refreshTokenHash === currentHash) {
currentHash = data.refreshTokenHash;
return Promise.resolve({ count: 1 });
}
return Promise.resolve({ count: 0 });
},
);
const results = await Promise.allSettled([
service.refresh(oldToken),
service.refresh(oldToken),
]);
const rejected = results.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1);
expect(rejected?.reason).toEqual(expect.any(UnauthorizedException));
expect(prisma.session.updateMany).toHaveBeenCalledTimes(2);
expect(
prisma.session.updateMany.mock.calls.map(
([input]) =>
(input as { where: { id?: string; refreshTokenHash?: string } })
.where,
),
).toContainEqual({ id: session.id, refreshTokenHash: hash(oldToken) });
expect(prisma.session.update).not.toHaveBeenCalled();
});
});