Repository navigation
refactor(admin-token): migrate from server #2740
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
0626db1
fix(admin-token): optional withRevoked, service-level expiration chec…
shikanime cfaacc4
test(admin-token): lock controller permission matrix
shikanime 35c9251
fix(admin-token): drop invalid guard metadata specs
shikanime c521713
test(admin-token): assert prisma calls via toHaveBeenCalledWith
shikanime 1fa931a
refactor(admin-token): move permissions mapping to controller
shikanime cc66d44
refactor(shared): drop duplicate CreateAdminTokenBody export in favor…
shikanime 9b4d31f
test(admin-token): stub list in e2e mock to return an empty page
shikanime 8ca2a5b
fix(admin-token): coerce contract expirationDate to Date at the servi…
shikanime 2ad29bf
test(admin-token): drop controller delegation specs, keep coercion guard
shikanime be63f4a
test(admin-token): drop unused type alias after delegation-spec removal
shikanime 5052d83
refactor(admin-token): share spec factories in testing utils, drop Re…
shikanime cd1c173
refactor(admin-token): assemble exposed token in controller
shikanime 1052f68
chore(admin-token): drop tooling tag from test comment
shikanime 1a64017
refactor(admin-token): inline transaction client type
shikanime e13557e
refactor(admin-token): extract status filter into helper
shikanime fdab474
refactor(admin-token): rename record type to AdminToken
shikanime e9a633a
refactor(admin-token): rename test factory to makeAdminToken
shikanime 56a8d37
refactor(admin-token): inline listed-token fixture at its single call…
shikanime 3a60976
fix(admin-token): update fixture import after inlining
shikanime 7dd88b9
refactor(admin-token): split row and selected-payload token fixtures
shikanime 2098928
refactor(admin-token): name the token row fixture type
shikanime b4e5850
refactor(admin-token): extract makeAdminTokenOwner from makeAdminToken
shikanime 5c5e483
refactor(admin-token): accept owner overrides in makeAdminToken
shikanime 225ee62
refactor(admin-token): extract makeAdminTokenOwner from makeAdminToken
shikanime a4b0fa4
refactor(admin-token): align queries types with Record naming
shikanime f530b9e
refactor(admin-token): simplify makeAdminTokenRecord overrides to Par…
shikanime a1d29e6
refactor(admin-token): rename AdminTokenRow to AdminTokenRecordWithHash
shikanime File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
90 changes: 90 additions & 0 deletions
90
apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import type { Prisma } from '@prisma/client' | ||
| import type { DeepMockProxy } from 'vitest-mock-extended' | ||
| import { faker } from '@faker-js/faker' | ||
| import { beforeEach, describe, expect, it } from 'vitest' | ||
| import { mockDeep } from 'vitest-mock-extended' | ||
| import { | ||
| adminTokenSelect, | ||
| createAdminToken, | ||
| createBotUser, | ||
| listAdminTokens, | ||
| revokeAdminToken, | ||
| } from './admin-token-queries.utils' | ||
|
|
||
| describe('admin-token-queries.utils', () => { | ||
| let tx: DeepMockProxy<Prisma.TransactionClient> | ||
|
|
||
| beforeEach(() => { | ||
| tx = mockDeep<Prisma.TransactionClient>() | ||
| }) | ||
|
|
||
| describe('listAdminTokens', () => { | ||
| it('filters to active tokens by default', async () => { | ||
| tx.adminToken.findMany.mockResolvedValue([]) | ||
|
|
||
| await listAdminTokens(tx, false) | ||
|
|
||
| expect(tx.adminToken.findMany).toHaveBeenCalledWith( | ||
| expect.objectContaining({ where: { status: 'active' }, select: adminTokenSelect }), | ||
| ) | ||
| }) | ||
|
|
||
| it('includes revoked tokens when withRevoked is true', async () => { | ||
| tx.adminToken.findMany.mockResolvedValue([]) | ||
|
|
||
| await listAdminTokens(tx, true) | ||
|
|
||
| expect(tx.adminToken.findMany).toHaveBeenCalledWith( | ||
| expect.objectContaining({ where: { status: { in: ['active', 'revoked'] } }, select: adminTokenSelect }), | ||
| ) | ||
| }) | ||
| }) | ||
|
|
||
| describe('createBotUser', () => { | ||
| it('creates a bot user with a derived email', async () => { | ||
| const botUserId = faker.string.uuid() | ||
| const name = faker.person.fullName() | ||
|
|
||
| await createBotUser(tx, { botUserId, name }) | ||
|
|
||
| expect(tx.user.create).toHaveBeenCalledWith({ | ||
| data: { | ||
| firstName: 'Bot Admin', | ||
| lastName: name, | ||
| type: 'bot', | ||
| id: botUserId, | ||
| email: `${botUserId}@bot.io`, | ||
| }, | ||
| }) | ||
| }) | ||
| }) | ||
|
|
||
| describe('createAdminToken', () => { | ||
| it('creates an admin token with the provided fields', async () => { | ||
| const data = { | ||
| name: faker.word.noun(), | ||
| permissions: 4n, | ||
| expirationDate: null, | ||
| hash: faker.string.alphanumeric(64), | ||
| userId: faker.string.uuid(), | ||
| } | ||
|
|
||
| await createAdminToken(tx, data) | ||
|
|
||
| expect(tx.adminToken.create).toHaveBeenCalledWith({ data, select: adminTokenSelect }) | ||
| }) | ||
| }) | ||
|
|
||
| describe('revokeAdminToken', () => { | ||
| it('marks the token revoked with a fresh expiration date', async () => { | ||
| const id = faker.string.uuid() | ||
|
|
||
| await revokeAdminToken(tx, id) | ||
|
|
||
| expect(tx.adminToken.updateMany).toHaveBeenCalledWith({ | ||
| where: { id }, | ||
| data: { status: 'revoked', expirationDate: expect.any(Date) }, | ||
| }) | ||
| }) | ||
| }) | ||
| }) |
82 changes: 82 additions & 0 deletions
82
apps/server-nestjs/src/modules/admin-token/admin-token-queries.utils.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import type { Prisma } from '@prisma/client' | ||
|
|
||
| export const adminTokenOwnerSelect = { | ||
| id: true, | ||
| email: true, | ||
| firstName: true, | ||
| lastName: true, | ||
| type: true, | ||
| } satisfies Prisma.UserSelect | ||
|
|
||
| export const adminTokenSelect = { | ||
| id: true, | ||
| name: true, | ||
| permissions: true, | ||
| lastUse: true, | ||
| expirationDate: true, | ||
| status: true, | ||
| createdAt: true, | ||
| userId: true, | ||
| owner: { | ||
| select: adminTokenOwnerSelect, | ||
| }, | ||
| } satisfies Prisma.AdminTokenSelect | ||
|
|
||
| export type AdminTokenRecord = Prisma.AdminTokenGetPayload<{ | ||
| select: typeof adminTokenSelect | ||
| }> | ||
|
|
||
| function adminTokenWhere(withRevoked: boolean): Prisma.AdminTokenWhereInput { | ||
| return withRevoked | ||
| ? { status: { in: ['active', 'revoked'] } } | ||
| : { status: 'active' } | ||
| } | ||
|
|
||
| export function listAdminTokens(tx: Prisma.TransactionClient, withRevoked: boolean) { | ||
| return tx.adminToken.findMany({ | ||
| where: adminTokenWhere(withRevoked), | ||
| select: adminTokenSelect, | ||
| orderBy: [{ status: 'asc' }, { createdAt: 'asc' }], | ||
| }) | ||
| } | ||
|
|
||
| export function createBotUser(tx: Prisma.TransactionClient, data: { botUserId: string, name: string }) { | ||
| return tx.user.create({ | ||
| data: { | ||
| firstName: 'Bot Admin', | ||
| lastName: data.name, | ||
| type: 'bot', | ||
| id: data.botUserId, | ||
| email: `${data.botUserId}@bot.io`, | ||
| }, | ||
| }) | ||
| } | ||
|
|
||
| export function createAdminToken(tx: Prisma.TransactionClient, data: { | ||
| name: string | ||
| permissions: bigint | ||
| expirationDate: Date | null | ||
| hash: string | ||
| userId: string | ||
| }) { | ||
| return tx.adminToken.create({ | ||
| data: { | ||
| name: data.name, | ||
| permissions: data.permissions, | ||
| expirationDate: data.expirationDate, | ||
| hash: data.hash, | ||
| userId: data.userId, | ||
| }, | ||
| select: adminTokenSelect, | ||
| }) | ||
| } | ||
|
|
||
| export function revokeAdminToken(tx: Prisma.TransactionClient, id: string) { | ||
| return tx.adminToken.updateMany({ | ||
| where: { id }, | ||
| data: { | ||
| status: 'revoked', | ||
| expirationDate: new Date(), | ||
| }, | ||
| }) | ||
| } |
35 changes: 35 additions & 0 deletions
35
apps/server-nestjs/src/modules/admin-token/admin-token-testing.utils.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import type { AdminTokenRecord } from './admin-token-queries.utils' | ||
| import { faker } from '@faker-js/faker' | ||
|
|
||
| export function makeAdminTokenOwner(overrides: Partial<AdminTokenRecord['owner']> = {}): AdminTokenRecord['owner'] { | ||
| return { | ||
| id: faker.string.uuid(), | ||
| email: faker.internet.email().toLowerCase(), | ||
| firstName: faker.person.firstName(), | ||
| lastName: faker.person.lastName(), | ||
| type: 'bot', | ||
| ...overrides, | ||
| } | ||
| } | ||
|
|
||
| export function makeAdminTokenRecord(overrides: Partial<AdminTokenRecord> = {}): AdminTokenRecord { | ||
| const owner = overrides.owner ?? makeAdminTokenOwner() | ||
| return { | ||
| id: faker.string.uuid(), | ||
| name: 'my-token', | ||
| permissions: 4n, | ||
| lastUse: null, | ||
| expirationDate: null, | ||
| status: 'active', | ||
| createdAt: faker.date.past(), | ||
| userId: owner.id, | ||
| owner, | ||
| ...overrides, | ||
| } | ||
| } | ||
|
|
||
| export type AdminTokenRecordWithHash = AdminTokenRecord & { hash: string } | ||
|
|
||
| export function makeAdminTokenRecordWithHash(overrides: Partial<AdminTokenRecord> = {}): AdminTokenRecordWithHash { | ||
| return { ...makeAdminTokenRecord(overrides), hash: faker.string.hexadecimal({ length: 64 }) } | ||
| } |
58 changes: 58 additions & 0 deletions
58
apps/server-nestjs/src/modules/admin-token/admin-token.controller.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import type { TestingModule } from '@nestjs/testing' | ||
| import type { MockProxy } from 'vitest-mock-extended' | ||
| import { Test } from '@nestjs/testing' | ||
| import { beforeEach, describe, expect, it } from 'vitest' | ||
| import { mock } from 'vitest-mock-extended' | ||
| import { UserGuard } from '../infrastructure/permission/user/user.guard' | ||
| import { makeAdminTokenRecord } from './admin-token-testing.utils' | ||
| import { AdminTokenController } from './admin-token.controller' | ||
| import { AdminTokenService } from './admin-token.service' | ||
|
|
||
| describe('adminTokenController', () => { | ||
| let module: TestingModule | ||
| let controller: AdminTokenController | ||
| let service: MockProxy<AdminTokenService> | ||
|
|
||
| const token = makeAdminTokenRecord({ permissions: 0n }) | ||
|
|
||
| beforeEach(async () => { | ||
| service = mock<AdminTokenService>() | ||
|
|
||
| module = await Test.createTestingModule({ | ||
| controllers: [AdminTokenController], | ||
| providers: [ | ||
| { provide: AdminTokenService, useValue: service }, | ||
| ], | ||
| }) | ||
| .overrideGuard(UserGuard) | ||
| .useValue({ canActivate: () => true }) | ||
| .compile() | ||
|
|
||
| controller = module.get<AdminTokenController>(AdminTokenController) | ||
| }) | ||
|
|
||
| it('should be defined', () => { | ||
| expect(controller).toBeDefined() | ||
| }) | ||
|
|
||
| it('delegates list with withRevoked coerced to boolean and serializes permissions', async () => { | ||
| service.list.mockResolvedValue([token]) | ||
|
|
||
| const [listed] = await controller.list(true) | ||
| expect(listed.permissions).toBe('0') | ||
| expect(service.list).toHaveBeenCalledWith(true) | ||
|
|
||
| await controller.list(false) | ||
| expect(service.list).toHaveBeenLastCalledWith(false) | ||
| }) | ||
|
|
||
| it('delegates create and assembles the exposed token', async () => { | ||
| service.create.mockResolvedValue({ token, password: 'plain-password' }) | ||
|
|
||
| const result = await controller.create({ name: token.name, permissions: '0', expirationDate: null }) | ||
|
|
||
| expect(service.create).toHaveBeenCalledWith({ name: token.name, permissions: '0', expirationDate: null }) | ||
| expect(result.permissions).toBe('0') | ||
| expect(result.password).toBe('plain-password') | ||
| }) | ||
| }) |
38 changes: 38 additions & 0 deletions
38
apps/server-nestjs/src/modules/admin-token/admin-token.controller.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import type { CreateAdminTokenBody } from '@cpn-console/shared' | ||
| import { CoerceBooleanSchema, CreateAdminTokenBodySchema } from '@cpn-console/shared' | ||
| import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Inject, Param, ParseUUIDPipe, Post, Query, UseGuards } from '@nestjs/common' | ||
| import { RequireAdminPermission } from '../infrastructure/permission/user/user-admin-permission.decorator' | ||
| import { UserGuard } from '../infrastructure/permission/user/user.guard' | ||
| import { ZodValidationPipe } from '../infrastructure/pipe/zod-validation.pipe' | ||
| import { AdminTokenService } from './admin-token.service' | ||
| import { toAdminToken, toExposedAdminToken } from './admin-token.utils' | ||
|
|
||
| @Controller('api/v1/admin/tokens') | ||
| @UseGuards(UserGuard) | ||
| export class AdminTokenController { | ||
| constructor(@Inject(AdminTokenService) private readonly service: AdminTokenService) {} | ||
|
|
||
| @Get() | ||
| @RequireAdminPermission('ListAdminToken') | ||
| async list(@Query('withRevoked', new ZodValidationPipe(CoerceBooleanSchema.optional())) withRevoked?: boolean) { | ||
| const tokens = await this.service.list(withRevoked === true) | ||
| return tokens.map(toAdminToken) | ||
| } | ||
|
|
||
| @Post() | ||
| @HttpCode(HttpStatus.CREATED) | ||
| @RequireAdminPermission('ManageAdminToken') | ||
| async create( | ||
|
shikanime marked this conversation as resolved.
|
||
| @Body(new ZodValidationPipe(CreateAdminTokenBodySchema)) data: CreateAdminTokenBody, | ||
| ) { | ||
| const { token, password } = await this.service.create(data) | ||
| return toExposedAdminToken(token, password) | ||
| } | ||
|
|
||
| @Delete(':tokenId') | ||
| @HttpCode(HttpStatus.NO_CONTENT) | ||
| @RequireAdminPermission('ManageAdminToken') | ||
| async revoke(@Param('tokenId', ParseUUIDPipe) tokenId: string): Promise<void> { | ||
| return this.service.revoke(tokenId) | ||
| } | ||
| } | ||
12 changes: 12 additions & 0 deletions
12
apps/server-nestjs/src/modules/admin-token/admin-token.module.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { Module } from '@nestjs/common' | ||
| import { InfrastructureModule } from '../infrastructure/infrastructure.module' | ||
| import { AdminTokenController } from './admin-token.controller' | ||
| import { AdminTokenService } from './admin-token.service' | ||
|
|
||
| @Module({ | ||
| imports: [InfrastructureModule], | ||
| controllers: [AdminTokenController], | ||
| providers: [AdminTokenService], | ||
| exports: [AdminTokenService], | ||
| }) | ||
| export class AdminTokenModule {} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.