0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-11 02:23:09 -05:00

refactor: api key spec to use factories (#16776)

This commit is contained in:
Jason Rasmussen 2025-03-10 12:04:35 -04:00 committed by GitHub
parent fe959b2f05
commit e97df503f2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 150 additions and 82 deletions

View file

@ -4,13 +4,13 @@ import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpda
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { APIKeyService } from 'src/services/api-key.service'; import { ApiKeyService } from 'src/services/api-key.service';
import { UUIDParamDto } from 'src/validation'; import { UUIDParamDto } from 'src/validation';
@ApiTags('API Keys') @ApiTags('API Keys')
@Controller('api-keys') @Controller('api-keys')
export class APIKeyController { export class APIKeyController {
constructor(private service: APIKeyService) {} constructor(private service: ApiKeyService) {}
@Post() @Post()
@Authenticated({ permission: Permission.API_KEY_CREATE }) @Authenticated({ permission: Permission.API_KEY_CREATE })

View file

@ -29,6 +29,15 @@ export type AuthApiKey = {
permissions: Permission[]; permissions: Permission[];
}; };
export type ApiKey = {
id: string;
name: string;
userId: string;
createdAt: Date;
updatedAt: Date;
permissions: Permission[];
};
export type User = { export type User = {
id: string; id: string;
name: string; name: string;

View file

@ -12,7 +12,7 @@ export class ApiKeyRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {} constructor(@InjectKysely() private db: Kysely<DB>) {}
create(dto: Insertable<ApiKeys>) { create(dto: Insertable<ApiKeys>) {
return this.db.insertInto('api_keys').values(dto).returningAll().executeTakeFirstOrThrow(); return this.db.insertInto('api_keys').values(dto).returning(columns.apiKey).executeTakeFirstOrThrow();
} }
async update(userId: string, id: string, dto: Updateable<ApiKeys>) { async update(userId: string, id: string, dto: Updateable<ApiKeys>) {
@ -21,7 +21,7 @@ export class ApiKeyRepository {
.set(dto) .set(dto)
.where('api_keys.userId', '=', userId) .where('api_keys.userId', '=', userId)
.where('id', '=', asUuid(id)) .where('id', '=', asUuid(id))
.returningAll() .returning(columns.apiKey)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
} }

View file

@ -1,112 +1,145 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { Permission } from 'src/enum'; import { Permission } from 'src/enum';
import { APIKeyService } from 'src/services/api-key.service'; import { ApiKeyService } from 'src/services/api-key.service';
import { keyStub } from 'test/fixtures/api-key.stub'; import { factory, newUuid } from 'test/small.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
describe(APIKeyService.name, () => { describe(ApiKeyService.name, () => {
let sut: APIKeyService; let sut: ApiKeyService;
let mocks: ServiceMocks; let mocks: ServiceMocks;
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(APIKeyService)); ({ sut, mocks } = newTestService(ApiKeyService));
}); });
describe('create', () => { describe('create', () => {
it('should create a new key', async () => { it('should create a new key', async () => {
mocks.apiKey.create.mockResolvedValue(keyStub.admin); const auth = factory.auth();
await sut.create(authStub.admin, { name: 'Test Key', permissions: [Permission.ALL] }); const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.ALL] });
const key = 'super-secret';
mocks.crypto.newPassword.mockReturnValue(key);
mocks.apiKey.create.mockResolvedValue(apiKey);
await sut.create(auth, { name: apiKey.name, permissions: apiKey.permissions });
expect(mocks.apiKey.create).toHaveBeenCalledWith({ expect(mocks.apiKey.create).toHaveBeenCalledWith({
key: 'cmFuZG9tLWJ5dGVz (hashed)', key: 'super-secret (hashed)',
name: 'Test Key', name: apiKey.name,
permissions: [Permission.ALL], permissions: apiKey.permissions,
userId: authStub.admin.user.id, userId: apiKey.userId,
}); });
expect(mocks.crypto.newPassword).toHaveBeenCalled(); expect(mocks.crypto.newPassword).toHaveBeenCalled();
expect(mocks.crypto.hashSha256).toHaveBeenCalled(); expect(mocks.crypto.hashSha256).toHaveBeenCalled();
}); });
it('should not require a name', async () => { it('should not require a name', async () => {
mocks.apiKey.create.mockResolvedValue(keyStub.admin); const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
const key = 'super-secret';
await sut.create(authStub.admin, { permissions: [Permission.ALL] }); mocks.crypto.newPassword.mockReturnValue(key);
mocks.apiKey.create.mockResolvedValue(apiKey);
await sut.create(auth, { permissions: [Permission.ALL] });
expect(mocks.apiKey.create).toHaveBeenCalledWith({ expect(mocks.apiKey.create).toHaveBeenCalledWith({
key: 'cmFuZG9tLWJ5dGVz (hashed)', key: 'super-secret (hashed)',
name: 'API Key', name: 'API Key',
permissions: [Permission.ALL], permissions: [Permission.ALL],
userId: authStub.admin.user.id, userId: auth.user.id,
}); });
expect(mocks.crypto.newPassword).toHaveBeenCalled(); expect(mocks.crypto.newPassword).toHaveBeenCalled();
expect(mocks.crypto.hashSha256).toHaveBeenCalled(); expect(mocks.crypto.hashSha256).toHaveBeenCalled();
}); });
it('should throw an error if the api key does not have sufficient permissions', async () => { it('should throw an error if the api key does not have sufficient permissions', async () => {
await expect( const auth = factory.auth({ apiKey: factory.authApiKey({ permissions: [Permission.ASSET_READ] }) });
sut.create({ ...authStub.admin, apiKey: keyStub.authKey }, { permissions: [Permission.ASSET_READ] }),
).rejects.toBeInstanceOf(BadRequestException); await expect(sut.create(auth, { permissions: [Permission.ASSET_UPDATE] })).rejects.toBeInstanceOf(
BadRequestException,
);
}); });
}); });
describe('update', () => { describe('update', () => {
it('should throw an error if the key is not found', async () => { it('should throw an error if the key is not found', async () => {
await expect(sut.update(authStub.admin, 'random-guid', { name: 'New Name' })).rejects.toBeInstanceOf( const id = newUuid();
BadRequestException, const auth = factory.auth();
);
expect(mocks.apiKey.update).not.toHaveBeenCalledWith('random-guid'); await expect(sut.update(auth, id, { name: 'New Name' })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.apiKey.update).not.toHaveBeenCalledWith(id);
}); });
it('should update a key', async () => { it('should update a key', async () => {
mocks.apiKey.getById.mockResolvedValue(keyStub.admin); const auth = factory.auth();
mocks.apiKey.update.mockResolvedValue(keyStub.admin); const apiKey = factory.apiKey({ userId: auth.user.id });
const newName = 'New name';
await sut.update(authStub.admin, 'random-guid', { name: 'New Name' }); mocks.apiKey.getById.mockResolvedValue(apiKey);
mocks.apiKey.update.mockResolvedValue(apiKey);
expect(mocks.apiKey.update).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid', { name: 'New Name' }); await sut.update(auth, apiKey.id, { name: newName });
expect(mocks.apiKey.update).toHaveBeenCalledWith(auth.user.id, apiKey.id, { name: newName });
}); });
}); });
describe('delete', () => { describe('delete', () => {
it('should throw an error if the key is not found', async () => { it('should throw an error if the key is not found', async () => {
await expect(sut.delete(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); const auth = factory.auth();
const id = newUuid();
expect(mocks.apiKey.delete).not.toHaveBeenCalledWith('random-guid'); await expect(sut.delete(auth, id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.apiKey.delete).not.toHaveBeenCalledWith(id);
}); });
it('should delete a key', async () => { it('should delete a key', async () => {
mocks.apiKey.getById.mockResolvedValue(keyStub.admin); const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
await sut.delete(authStub.admin, 'random-guid'); mocks.apiKey.getById.mockResolvedValue(apiKey);
expect(mocks.apiKey.delete).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); await sut.delete(auth, apiKey.id);
expect(mocks.apiKey.delete).toHaveBeenCalledWith(auth.user.id, apiKey.id);
}); });
}); });
describe('getById', () => { describe('getById', () => {
it('should throw an error if the key is not found', async () => { it('should throw an error if the key is not found', async () => {
await expect(sut.getById(authStub.admin, 'random-guid')).rejects.toBeInstanceOf(BadRequestException); const auth = factory.auth();
const id = newUuid();
expect(mocks.apiKey.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); await expect(sut.getById(auth, id)).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, id);
}); });
it('should get a key by id', async () => { it('should get a key by id', async () => {
mocks.apiKey.getById.mockResolvedValue(keyStub.admin); const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
await sut.getById(authStub.admin, 'random-guid'); mocks.apiKey.getById.mockResolvedValue(apiKey);
expect(mocks.apiKey.getById).toHaveBeenCalledWith(authStub.admin.user.id, 'random-guid'); await sut.getById(auth, apiKey.id);
expect(mocks.apiKey.getById).toHaveBeenCalledWith(auth.user.id, apiKey.id);
}); });
}); });
describe('getAll', () => { describe('getAll', () => {
it('should return all the keys for a user', async () => { it('should return all the keys for a user', async () => {
mocks.apiKey.getByUserId.mockResolvedValue([keyStub.admin]); const auth = factory.auth();
const apiKey = factory.apiKey({ userId: auth.user.id });
await expect(sut.getAll(authStub.admin)).resolves.toHaveLength(1); mocks.apiKey.getByUserId.mockResolvedValue([apiKey]);
expect(mocks.apiKey.getByUserId).toHaveBeenCalledWith(authStub.admin.user.id); await expect(sut.getAll(auth)).resolves.toHaveLength(1);
expect(mocks.apiKey.getByUserId).toHaveBeenCalledWith(auth.user.id);
}); });
}); });
}); });

View file

@ -7,7 +7,7 @@ import { ApiKeyItem } from 'src/types';
import { isGranted } from 'src/utils/access'; import { isGranted } from 'src/utils/access';
@Injectable() @Injectable()
export class APIKeyService extends BaseService { export class ApiKeyService extends BaseService {
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> { async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
const secret = this.cryptoRepository.newPassword(32); const secret = this.cryptoRepository.newPassword(32);

View file

@ -4,12 +4,11 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum'; import { AuthType, Permission } from 'src/enum';
import { AuthService } from 'src/services/auth.service'; import { AuthService } from 'src/services/auth.service';
import { keyStub } from 'test/fixtures/api-key.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { sessionStub } from 'test/fixtures/session.stub'; import { sessionStub } from 'test/fixtures/session.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
const oauthResponse = { const oauthResponse = {
@ -398,7 +397,10 @@ describe('AuthService', () => {
}); });
it('should throw an error if api key has insufficient permissions', async () => { it('should throw an error if api key has insufficient permissions', async () => {
mocks.apiKey.getKey.mockResolvedValue(keyStub.authKey); const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [] });
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-api-key': 'auth_token' }, headers: { 'x-api-key': 'auth_token' },
@ -409,14 +411,18 @@ describe('AuthService', () => {
}); });
it('should return an auth dto', async () => { it('should return an auth dto', async () => {
mocks.apiKey.getKey.mockResolvedValue(keyStub.authKey); const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [] });
mocks.apiKey.getKey.mockResolvedValue({ ...authApiKey, user: authUser });
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-api-key': 'auth_token' }, headers: { 'x-api-key': 'auth_token' },
queryParams: {}, queryParams: {},
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' }, metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
}), }),
).resolves.toEqual({ user: userStub.admin, apiKey: keyStub.authKey }); ).resolves.toEqual({ user: authUser, apiKey: expect.objectContaining(authApiKey) });
expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)'); expect(mocks.apiKey.getKey).toHaveBeenCalledWith('auth_token (hashed)');
}); });
}); });
@ -622,19 +628,27 @@ describe('AuthService', () => {
describe('link', () => { describe('link', () => {
it('should link an account', async () => { it('should link an account', async () => {
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [] });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.update.mockResolvedValue(userStub.user1); mocks.user.update.mockResolvedValue(userStub.user1);
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' }); await sut.link(auth, { url: 'http://immich/user-settings?code=abc123' });
expect(mocks.user.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: sub }); expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: sub });
}); });
it('should not link an already linked oauth.sub', async () => { it('should not link an already linked oauth.sub', async () => {
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [] });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity); mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf( await expect(sut.link(auth, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
@ -644,12 +658,16 @@ describe('AuthService', () => {
describe('unlink', () => { describe('unlink', () => {
it('should unlink an account', async () => { it('should unlink an account', async () => {
const authUser = factory.authUser();
const authApiKey = factory.authApiKey({ permissions: [] });
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled); mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.update.mockResolvedValue(userStub.user1); mocks.user.update.mockResolvedValue(userStub.user1);
await sut.unlink(authStub.user1); await sut.unlink(auth);
expect(mocks.user.update).toHaveBeenCalledWith(authStub.user1.user.id, { oauthId: '' }); expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: '' });
}); });
}); });
}); });

View file

@ -1,6 +1,6 @@
import { ActivityService } from 'src/services/activity.service'; import { ActivityService } from 'src/services/activity.service';
import { AlbumService } from 'src/services/album.service'; import { AlbumService } from 'src/services/album.service';
import { APIKeyService } from 'src/services/api-key.service'; import { ApiKeyService } from 'src/services/api-key.service';
import { ApiService } from 'src/services/api.service'; import { ApiService } from 'src/services/api.service';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { AssetService } from 'src/services/asset.service'; import { AssetService } from 'src/services/asset.service';
@ -40,7 +40,7 @@ import { VersionService } from 'src/services/version.service';
import { ViewService } from 'src/services/view.service'; import { ViewService } from 'src/services/view.service';
export const services = [ export const services = [
APIKeyService, ApiKeyService,
ActivityService, ActivityService,
AlbumService, AlbumService,
ApiService, ApiService,

View file

@ -1,20 +0,0 @@
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
export const keyStub = {
authKey: Object.freeze({
id: 'my-random-guid',
key: 'my-api-key (hashed)',
user: userStub.admin,
permissions: [],
} as any),
admin: Object.freeze({
id: 'my-random-guid',
name: 'My Key',
key: 'my-api-key (hashed)',
userId: authStub.admin.user.id,
user: userStub.admin,
permissions: [],
} as any),
};

View file

@ -1,7 +1,8 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Asset, AuthUser, Library, User } from 'src/database'; import { ApiKey, Asset, AuthApiKey, AuthUser, Library, User } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { OnThisDayData } from 'src/entities/memory.entity'; import { OnThisDayData } from 'src/entities/memory.entity';
import { AssetStatus, AssetType, MemoryType } from 'src/enum'; import { AssetStatus, AssetType, MemoryType, Permission } from 'src/enum';
import { ActivityItem, MemoryItem } from 'src/types'; import { ActivityItem, MemoryItem } from 'src/types';
export const newUuid = () => randomUUID() as string; export const newUuid = () => randomUUID() as string;
@ -13,11 +14,25 @@ export const newDate = () => new Date();
export const newUpdateId = () => 'uuid-v7'; export const newUpdateId = () => 'uuid-v7';
export const newSha1 = () => Buffer.from('this is a fake hash'); export const newSha1 = () => Buffer.from('this is a fake hash');
const authFactory = (user: Partial<AuthUser> = {}) => ({ const authFactory = ({ apiKey, ...user }: Partial<AuthUser> & { apiKey?: Partial<AuthApiKey> } = {}) => {
user: authUserFactory(user), const auth: AuthDto = {
user: authUserFactory(user),
};
if (apiKey) {
auth.apiKey = authApiKeyFactory(apiKey);
}
return auth;
};
const authApiKeyFactory = (apiKey: Partial<AuthApiKey> = {}) => ({
id: newUuid(),
permissions: [Permission.ALL],
...apiKey,
}); });
const authUserFactory = (authUser: Partial<AuthUser>) => ({ const authUserFactory = (authUser: Partial<AuthUser> = {}) => ({
id: newUuid(), id: newUuid(),
isAdmin: false, isAdmin: false,
name: 'Test User', name: 'Test User',
@ -86,6 +101,17 @@ const activityFactory = (activity: Partial<ActivityItem> = {}) => {
}; };
}; };
const apiKeyFactory = (apiKey: Partial<ApiKey> = {}) => ({
id: newUuid(),
userId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUpdateId(),
name: 'Api Key',
permissions: [Permission.ALL],
...apiKey,
});
const libraryFactory = (library: Partial<Library> = {}) => ({ const libraryFactory = (library: Partial<Library> = {}) => ({
id: newUuid(), id: newUuid(),
createdAt: newDate(), createdAt: newDate(),
@ -121,8 +147,10 @@ const memoryFactory = (memory: Partial<MemoryItem> = {}) => ({
export const factory = { export const factory = {
activity: activityFactory, activity: activityFactory,
apiKey: apiKeyFactory,
asset: assetFactory, asset: assetFactory,
auth: authFactory, auth: authFactory,
authApiKey: authApiKeyFactory,
authUser: authUserFactory, authUser: authUserFactory,
library: libraryFactory, library: libraryFactory,
memory: memoryFactory, memory: memoryFactory,