Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -86,15 +86,17 @@ model ApiKey {
id String @id @default(cuid())
userId String
name String
key String @unique
keyHash String @unique
expiresAt DateTime?
lastUsedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId])
@@index([expiresAt])
@@index([revokedAt])
@@map("api_keys")
}

Expand Down
33 changes: 33 additions & 0 deletions src/api-keys/api-keys.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Body, Controller, Delete, Get, Param, Post, Req, UseGuards } from '@nestjs/common';

import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { ApiKeysService } from './api-keys.service';

@Controller('api-keys')
@UseGuards()
export class ApiKeysController {
constructor(private readonly apiKeysService: ApiKeysService) {}

@Post()
async create(@Req() request: any, @Body() dto: CreateApiKeyDto) {
const userId = request.user?.id ?? request.user?.userId;

const expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null;

return this.apiKeysService.create(userId, dto.name, expiresAt);
}

@Get()
async findAll(@Req() request: any) {
const userId = request.user?.id ?? request.user?.userId;

return this.apiKeysService.findAll(userId);
}

@Delete(':id')
async revoke(@Req() request: any, @Param('id') id: string) {
const userId = request.user?.id ?? request.user?.userId;

return this.apiKeysService.revoke(userId, id);
}
}
11 changes: 11 additions & 0 deletions src/api-keys/api-keys.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';

import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service';

@Module({
controllers: [ApiKeysController],
providers: [ApiKeysService],
exports: [ApiKeysService],
})
export class ApiKeysModule {}
166 changes: 166 additions & 0 deletions src/api-keys/api-keys.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { ConflictException, NotFoundException, UnauthorizedException } from '@nestjs/common';

import { ApiKeysService } from './api-keys.service';

describe('ApiKeysService', () => {
let service: ApiKeysService;

const prisma = {
apiKey: {
create: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
};

beforeEach(() => {
jest.clearAllMocks();
service = new ApiKeysService(prisma as any);
});

describe('create', () => {
it('creates an API key with an expiration date', async () => {
const expiresAt = new Date(Date.now() + 86_400_000);

prisma.apiKey.create.mockResolvedValue({
id: 'key-1',
name: 'Production',
expiresAt,
createdAt: new Date(),
});

const result = await service.create('user-1', 'Production', expiresAt);

expect(prisma.apiKey.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
userId: 'user-1',
name: 'Production',
expiresAt,
keyHash: expect.any(String),
}),
}),
);

expect(result.key).toMatch(/^sk_live_/);
expect(result.expiresAt).toEqual(expiresAt);
});

it('rejects an expired creation date', async () => {
const expiresAt = new Date(Date.now() - 1_000);

await expect(service.create('user-1', 'Expired', expiresAt)).rejects.toBeInstanceOf(
ConflictException,
);

expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
});

describe('validate', () => {
it('rejects an unknown API key', async () => {
prisma.apiKey.findUnique.mockResolvedValue(null);

await expect(service.validate('sk_live_invalid')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});

it('rejects a revoked API key', async () => {
prisma.apiKey.findUnique.mockResolvedValue({
id: 'key-1',
revokedAt: new Date(),
expiresAt: null,
user: {
id: 'user-1',
},
});

await expect(service.validate('sk_live_revoked')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});

it('rejects an expired API key', async () => {
prisma.apiKey.findUnique.mockResolvedValue({
id: 'key-1',
revokedAt: null,
expiresAt: new Date(Date.now() - 1_000),
user: {
id: 'user-1',
},
});

await expect(service.validate('sk_live_expired')).rejects.toBeInstanceOf(
UnauthorizedException,
);
});

it('updates lastUsedAt for a valid API key', async () => {
prisma.apiKey.findUnique.mockResolvedValue({
id: 'key-1',
revokedAt: null,
expiresAt: null,
user: {
id: 'user-1',
email: 'user@example.com',
},
});

const user = await service.validate('sk_live_valid');

expect(user.id).toBe('user-1');
expect(prisma.apiKey.update).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'key-1',
},
data: {
lastUsedAt: expect.any(Date),
},
}),
);
});
});

describe('revoke', () => {
it('rejects an API key owned by another user', async () => {
prisma.apiKey.findFirst.mockResolvedValue(null);

await expect(service.revoke('user-1', 'key-1')).rejects.toBeInstanceOf(NotFoundException);
});

it('revokes an owned API key', async () => {
prisma.apiKey.findFirst.mockResolvedValue({
id: 'key-1',
revokedAt: null,
});

prisma.apiKey.update.mockResolvedValue({
id: 'key-1',
name: 'Production',
expiresAt: null,
lastUsedAt: null,
revokedAt: new Date(),
createdAt: new Date(),
});

const result = await service.revoke('user-1', 'key-1');

expect(prisma.apiKey.update).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'key-1',
},
data: {
revokedAt: expect.any(Date),
},
}),
);

expect(result.id).toBe('key-1');
});
});
});
Loading
Loading