native auth

This commit is contained in:
2026-02-11 01:09:08 +08:00
parent 7191035748
commit 94dae77ddd
34 changed files with 650 additions and 1801 deletions

View File

@@ -1,449 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { URLSearchParams } from 'url';
import axios from 'axios';
import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import type { AuthenticatedUser } from './decorators/current-user.decorator';
import { User } from '../users/users.entity';
describe('AuthService', () => {
let service: AuthService;
const mockUser: User = {
id: 'uuid-123',
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
lastLoginAt: new Date('2024-01-01'),
activeDollId: null,
};
const mockUsersService: jest.Mocked<
Pick<UsersService, 'createFromToken' | 'findByKeycloakSub' | 'findOrCreate'>
> = {
createFromToken: jest.fn().mockResolvedValue(mockUser),
findByKeycloakSub: jest.fn().mockResolvedValue(null),
findOrCreate: jest.fn().mockResolvedValue(mockUser),
};
const mockAuthUser: AuthenticatedUser = {
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: UsersService,
useValue: mockUsersService,
},
{
provide: ConfigService,
useValue: {
get: (key: string) => {
if (key === 'JWT_ISSUER') return 'https://auth.example.com';
if (key === 'KEYCLOAK_CLIENT_ID') return 'friendolls-client';
if (key === 'KEYCLOAK_CLIENT_SECRET') return 'secret';
return undefined;
},
},
},
],
}).compile();
service = module.get<AuthService>(AuthService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('revokeToken', () => {
beforeEach(() => {
jest.spyOn(axios, 'post').mockReset();
});
it('should skip when config missing', async () => {
const missingConfigService = new ConfigService({});
const localService = new AuthService(
mockUsersService as unknown as UsersService,
missingConfigService,
);
const warnSpy = jest.spyOn<any, any>(localService['logger'], 'warn');
const result = await localService.revokeToken('rt');
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
});
it('should return true on successful revocation', async () => {
jest.spyOn(axios, 'post').mockResolvedValue({ status: 200 });
const result = await service.revokeToken('rt-success');
expect(result).toBe(true);
expect(axios.post).toHaveBeenCalledWith(
'https://auth.example.com/protocol/openid-connect/revoke',
expect.any(URLSearchParams),
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('should return false on non-2xx response', async () => {
jest.spyOn(axios, 'post').mockResolvedValue({ status: 400 });
const warnSpy = jest.spyOn<any, any>(service['logger'], 'warn');
const result = await service.revokeToken('rt-fail');
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
});
it('should return false on error', async () => {
jest.spyOn(axios, 'post').mockRejectedValue({ message: 'boom' });
const warnSpy = jest.spyOn<any, any>(service['logger'], 'warn');
const result = await service.revokeToken('rt-error');
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
});
});
describe('syncUserFromToken', () => {
it('should create a new user if user does not exist', async () => {
mockUsersService.createFromToken.mockResolvedValue(mockUser);
const result = await service.syncUserFromToken(mockAuthUser);
expect(result).toEqual(mockUser);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
});
});
it('should handle existing user via upsert', async () => {
const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') };
mockUsersService.createFromToken.mockResolvedValue(updatedUser);
const result = await service.syncUserFromToken(mockAuthUser);
expect(result).toEqual(updatedUser);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
});
});
it('should handle user with no email by using empty string', async () => {
const authUserNoEmail: AuthenticatedUser = {
keycloakSub: 'f:realm:user456',
name: 'No Email User',
};
mockUsersService.createFromToken.mockResolvedValue({
...mockUser,
email: '',
name: 'No Email User',
});
await service.syncUserFromToken(authUserNoEmail);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({
email: '',
name: 'No Email User',
}),
);
});
it('should handle user with no name by using username or fallback', async () => {
const authUserNoName: AuthenticatedUser = {
keycloakSub: 'f:realm:user789',
username: 'fallbackuser',
};
mockUsersService.createFromToken.mockResolvedValue({
...mockUser,
name: 'fallbackuser',
});
await service.syncUserFromToken(authUserNoName);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({
name: 'fallbackuser',
}),
);
});
it('should use "Unknown User" when no name or username is available', async () => {
const authUserMinimal: AuthenticatedUser = {
keycloakSub: 'f:realm:minimal',
};
mockUsersService.createFromToken.mockResolvedValue({
...mockUser,
name: 'Unknown User',
});
await service.syncUserFromToken(authUserMinimal);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Unknown User',
}),
);
});
it('should handle empty keycloakSub gracefully', async () => {
const authUserEmptySub: AuthenticatedUser = {
keycloakSub: '',
email: 'empty@example.com',
name: 'Empty Sub User',
};
mockUsersService.createFromToken.mockResolvedValue({
...mockUser,
keycloakSub: '',
email: 'empty@example.com',
name: 'Empty Sub User',
});
await service.syncUserFromToken(authUserEmptySub);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({
keycloakSub: '',
email: 'empty@example.com',
name: 'Empty Sub User',
}),
);
});
it('should handle malformed keycloakSub', async () => {
const authUserMalformed: AuthenticatedUser = {
keycloakSub: 'invalid-format',
email: 'malformed@example.com',
name: 'Malformed User',
};
mockUsersService.createFromToken.mockResolvedValue({
...mockUser,
keycloakSub: 'invalid-format',
email: 'malformed@example.com',
name: 'Malformed User',
});
const result = await service.syncUserFromToken(authUserMalformed);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({
keycloakSub: 'invalid-format',
email: 'malformed@example.com',
name: 'Malformed User',
}),
);
expect(result.keycloakSub).toBe('invalid-format');
});
});
describe('ensureUserExists', () => {
it('should call findOrCreate with correct params', async () => {
mockUsersService.findOrCreate.mockResolvedValue(mockUser);
const result = await service.ensureUserExists(mockAuthUser);
expect(result).toEqual(mockUser);
expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
});
});
it('should handle missing username gracefully', async () => {
mockUsersService.findOrCreate.mockResolvedValue(mockUser);
const result = await service.ensureUserExists({
...mockAuthUser,
username: undefined,
});
expect(result).toEqual(mockUser);
expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: undefined,
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
});
});
});
describe('hasRole', () => {
it('should return true if user has the required role', () => {
const result = service.hasRole(mockAuthUser, 'user');
expect(result).toBe(true);
});
it('should return false if user does not have the required role', () => {
const result = service.hasRole(mockAuthUser, 'admin');
expect(result).toBe(false);
});
it('should return false if user has no roles', () => {
const authUserNoRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:noroles',
email: 'noroles@example.com',
name: 'No Roles User',
};
const result = service.hasRole(authUserNoRoles, 'user');
expect(result).toBe(false);
});
it('should return false if user roles is empty array', () => {
const authUserEmptyRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:emptyroles',
email: 'empty@example.com',
name: 'Empty Roles User',
roles: [],
};
const result = service.hasRole(authUserEmptyRoles, 'user');
expect(result).toBe(false);
});
});
describe('hasAnyRole', () => {
it('should return true if user has at least one of the required roles', () => {
const result = service.hasAnyRole(mockAuthUser, ['admin', 'premium']);
expect(result).toBe(true);
});
it('should return false if user has none of the required roles', () => {
const result = service.hasAnyRole(mockAuthUser, ['admin', 'moderator']);
expect(result).toBe(false);
});
it('should return false if user has no roles', () => {
const authUserNoRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:noroles',
email: 'noroles@example.com',
name: 'No Roles User',
};
const result = service.hasAnyRole(authUserNoRoles, ['admin', 'user']);
expect(result).toBe(false);
});
it('should return false if user roles is empty array', () => {
const authUserEmptyRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:emptyroles',
email: 'empty@example.com',
name: 'Empty Roles User',
roles: [],
};
const result = service.hasAnyRole(authUserEmptyRoles, ['admin', 'user']);
expect(result).toBe(false);
});
it('should handle multiple matching roles', () => {
const result = service.hasAnyRole(mockAuthUser, ['user', 'premium']);
expect(result).toBe(true);
});
});
describe('hasAllRoles', () => {
it('should return true if user has all of the required roles', () => {
const result = service.hasAllRoles(mockAuthUser, ['user', 'premium']);
expect(result).toBe(true);
});
it('should return false if user has only some of the required roles', () => {
const result = service.hasAllRoles(mockAuthUser, [
'user',
'premium',
'admin',
]);
expect(result).toBe(false);
});
it('should return false if user has none of the required roles', () => {
const result = service.hasAllRoles(mockAuthUser, ['admin', 'moderator']);
expect(result).toBe(false);
});
it('should return false if user has no roles', () => {
const authUserNoRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:noroles',
email: 'noroles@example.com',
name: 'No Roles User',
};
const result = service.hasAllRoles(authUserNoRoles, ['user']);
expect(result).toBe(false);
});
it('should return false if user roles is empty array', () => {
const authUserEmptyRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:emptyroles',
email: 'empty@example.com',
name: 'Empty Roles User',
roles: [],
};
const result = service.hasAllRoles(authUserEmptyRoles, ['user']);
expect(result).toBe(false);
});
it('should return true for single role check', () => {
const result = service.hasAllRoles(mockAuthUser, ['user']);
expect(result).toBe(true);
});
});
});