Skip to content
Open
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: 2 additions & 2 deletions apps/client/src/stores/token.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type {
CreatePersonalAccessTokenRequest,
PersonalAccessToken,
personalAccessTokenContract,
} from '@cpn-console/shared'
import { defineStore } from 'pinia'
import { apiClient, extractData } from '../api/xhr-client.js'
Expand All @@ -13,7 +13,7 @@ export const useTokenStore = defineStore('token', () => {
}

const createPersonalAccessToken = async (
body: typeof personalAccessTokenContract.createPersonalAccessToken.body._type,
body: CreatePersonalAccessTokenRequest,
) => {
return apiClient.PersonalAccessTokens.createPersonalAccessToken({
body,
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/views/profile/PersonalAccessTokens.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async function getAllTokens() {

const formProps = ref<{ exposedToken?: string }>({ exposedToken: undefined })
async function createToken(token: SimpleToken) {
const exposedToken = await tokenStore.createPersonalAccessToken(token)
const exposedToken = await tokenStore.createPersonalAccessToken({ ...token, expirationDate: new Date(token.expirationDate) })
formProps.value.exposedToken = exposedToken.password
getAllTokens()
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server-nestjs/src/main.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { StageModule } from './modules/stage/stage.module'
import { SystemConfigModule } from './modules/system-config/system-config.module'
import { SystemSettingsModule } from './modules/system-settings/system-settings.module'
import { UserModule } from './modules/user/user.module'
import { UserTokensModule } from './modules/user-tokens/user-tokens.module'
import { VersionModule } from './modules/version/version.module'
import { ZoneModule } from './modules/zone/zone.module'
import { getDotenvPaths } from './utils/dotenv.utils'
Expand Down Expand Up @@ -60,6 +61,7 @@ import { getDotenvPaths } from './utils/dotenv.utils'
SystemConfigModule,
SystemSettingsModule,
UserModule,
UserTokensModule,
Comment thread
shikanime marked this conversation as resolved.
VersionModule,
ZoneModule,
],
Expand Down
4 changes: 2 additions & 2 deletions apps/server-nestjs/src/modules/gitlab/gitlab.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { OnEvent } from '@nestjs/event-emitter'
import { trace } from '@opentelemetry/api'
import { gitlabConfigFactory } from '../../config/gitlab.config'
import { getAll } from '../../utils/iterable.utils'
import { daysAgo } from '@cpn-console/shared'
import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator'
import { capturePluginResult } from '../plugin/plugin.utils'
import { VaultClientService } from '../vault/vault-client.service'
Expand All @@ -35,7 +36,6 @@ import {
} from './gitlab.constants'
import {
adminRoleFlag,
daysAgoFromNow,
generateAccessLevelMapping,
generateAdminRoleMapping,
generateName,
Expand Down Expand Up @@ -530,7 +530,7 @@ export class GitlabService {
const createdTimeRaw = vaultSecret?.metadata?.created_time
if (!createdTimeRaw) return false
const createdTime = new Date(createdTimeRaw)
return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays
return daysAgo(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays
}

@StartActiveSpan()
Expand Down
4 changes: 0 additions & 4 deletions apps/server-nestjs/src/modules/gitlab/gitlab.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,10 +241,6 @@ export function isDeclaredRepo(project: ProjectWithDetails, repo: ProjectSchema)
export function getProjectPluginConfig(project: ProjectWithDetails, key: string) {
return project.plugins?.find(p => p.key === key)?.value
}
export function daysAgoFromNow(date: Date) {
return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24))
}

export function adminRoleFlag(user: Pick<ProjectWithDetails['members'][0]['user'], 'adminRoleIds'>, adminRoleIds: string[]): boolean | undefined {
if (adminRoleIds.length === 0) return undefined
return user.adminRoleIds?.some(id => adminRoleIds.includes(id))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { TestingModule } from '@nestjs/testing'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { createHash } from 'node:crypto'
import { faker } from '@faker-js/faker'
import { UnauthorizedException } from '@nestjs/common'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import { hashToken } from '../../../../utils/crypto.utils'
import { PrismaService } from '../../database/prisma.service'
import { makeAdminToken, makePersonalAccessToken } from '../auth-testing.utils'
import { DsoTokenService } from './dso-token.service'
Expand Down Expand Up @@ -40,7 +40,7 @@ describe('dsoTokenService', () => {
const result = await service.validateToken(rawToken, { includeAdminRoleIds: true, includeUserType: true })

expect(prisma.personalAccessToken.findFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { hash: createHash('sha256').update(rawToken).digest('hex') } }),
expect.objectContaining({ where: { hash: hashToken(rawToken) } }),
)
expect(result).toBeDefined()
if (result?.kind !== 'personal') throw new Error('Expected personal token result')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import type { FastifyRequest } from 'fastify'
import type { UserContext } from '../auth-user.decorator'
import type { AuthProvider, AuthRequirements } from '../auth.utils'
import type { AdminTokenWithOwner, AuthToken, PersonalAccessTokenWithOwner } from './dso-token.utils'
import { createHash } from 'node:crypto'
import { tokenHeaderName } from '@cpn-console/shared'
import { Inject, Injectable, Logger } from '@nestjs/common'
import { hashToken } from '../../../../utils/crypto.utils'
import { PrismaService } from '../../database/prisma.service'
import { makeAdminTokenSelect, makePersonalAccessTokenSelect, validateToken } from './dso-token.utils'

Expand Down Expand Up @@ -54,7 +54,7 @@ export class DsoTokenService implements AuthProvider {
requirements: Required<AuthRequirements>,
): Promise<AuthToken | undefined> {
this.logger.debug(`validateToken started`)
const hash = createHash('sha256').update(rawToken).digest('hex')
const hash = hashToken(rawToken)
const result = await this.findAndValidateToken(hash, requirements)
if (!result) {
this.logger.warn(`validateToken token not found`)
Expand Down
7 changes: 2 additions & 5 deletions apps/server-nestjs/src/modules/registry/registry.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { trace } from '@opentelemetry/api'
import { baseConfigFactory } from '../../config/base.config'
import { harborConfigFactory } from '../../config/harbor.config'
import { find } from '../../utils/iterable.utils'
import { daysAgo } from '@cpn-console/shared'
import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator'
import { capturePluginResult } from '../plugin/plugin.utils'
import { VaultClientService } from '../vault/vault-client.service'
Expand Down Expand Up @@ -138,7 +139,7 @@ export class RegistryService {
const createdTimeRaw = vaultSecret?.metadata?.created_time
if (!createdTimeRaw) return false
const createdTime = new Date(createdTimeRaw)
return daysAgoFromNow(createdTime) > this.harborConfig.robotRotationThresholdDays
return daysAgo(createdTime) > this.harborConfig.robotRotationThresholdDays
}

private async ensureProjectGroupMember(
Expand Down Expand Up @@ -427,10 +428,6 @@ function generateHarborAccessLevelMapping(args: { guest: string[], developer: st
return byGroupName
}

function daysAgoFromNow(date: Date) {
return Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60 * 24))
}

function generateRobotFullName(project: ProjectWithDetails, robotName: string) {
return `robot$${project.slug}+${robotName}`
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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 {
createUserToken,
deleteUserToken,
listUserTokens,
userTokenSelect,
} from './user-tokens-queries.utils'

describe('user-tokens-queries.utils', () => {
let tx: DeepMockProxy<Prisma.TransactionClient>

beforeEach(() => {
tx = mockDeep<Prisma.TransactionClient>()
})

describe('listUserTokens', () => {
it('scopes by userId and uses the shared select', async () => {
const userId = faker.string.uuid()
tx.personalAccessToken.findMany.mockResolvedValue([])

await listUserTokens(tx, userId)

expect(tx.personalAccessToken.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId }, select: userTokenSelect }),
)
})
})

describe('createUserToken', () => {
it('creates a personal access token with the provided fields', async () => {
const data = {
name: faker.word.noun(),
expirationDate: faker.date.future(),
hash: faker.string.alphanumeric(64),
userId: faker.string.uuid(),
}

await createUserToken(tx, data)

expect(tx.personalAccessToken.create).toHaveBeenCalledWith({ data, select: userTokenSelect })
})
})

describe('deleteUserToken', () => {
it('scopes the delete to both id and userId', async () => {
const id = faker.string.uuid()
const userId = faker.string.uuid()
tx.personalAccessToken.deleteMany.mockResolvedValue({ count: 1 })

const result = await deleteUserToken(tx, { id, userId })

expect(result).toEqual({ count: 1 })
expect(tx.personalAccessToken.deleteMany).toHaveBeenCalledWith({ where: { id, userId } })
})
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { Prisma } from '@prisma/client'

export const userTokenOwnerSelect = {
id: true,
email: true,
firstName: true,
lastName: true,
type: true,
} satisfies Prisma.UserSelect

export const userTokenSelect = {
id: true,
name: true,
lastUse: true,
expirationDate: true,
status: true,
createdAt: true,
userId: true,
owner: {
select: userTokenOwnerSelect,
},
} satisfies Prisma.PersonalAccessTokenSelect

export type UserToken = Prisma.PersonalAccessTokenGetPayload<{
select: typeof userTokenSelect
}>

export function listUserTokens(tx: Prisma.TransactionClient, userId: string) {
return tx.personalAccessToken.findMany({
where: { userId },
orderBy: [{ status: 'asc' }, { createdAt: 'asc' }],
select: userTokenSelect,
})
}

export function createUserToken(tx: Prisma.TransactionClient, data: {
name: string
expirationDate: Date
hash: string
userId: string
}) {
return tx.personalAccessToken.create({
data: {
name: data.name,
hash: data.hash,
expirationDate: data.expirationDate,
userId: data.userId,
},
select: userTokenSelect,
})
}

export function deleteUserToken(tx: Prisma.TransactionClient, where: {
id: string
userId: string
}) {
return tx.personalAccessToken.deleteMany({ where })
Comment thread
shikanime marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import type { TestingModule } from '@nestjs/testing'
import type { DeepMockProxy } from 'vitest-mock-extended'
import type { UserContext } from '../infrastructure/auth/auth-user.decorator'
import { endOfToday } from '@cpn-console/shared'
import { faker } from '@faker-js/faker'
import { Test } from '@nestjs/testing'
import { beforeEach, describe, expect, it } from 'vitest'
import { mockDeep } from 'vitest-mock-extended'
import { UserGuard } from '../infrastructure/permission/user/user.guard'
import { UserTokensController } from './user-tokens.controller'
import { UserTokensService } from './user-tokens.service'

describe('userTokensController', () => {
let module: TestingModule
let controller: UserTokensController
let service: DeepMockProxy<UserTokensService>

const user: UserContext = { userId: faker.string.uuid() }

beforeEach(async () => {
service = mockDeep<UserTokensService>()

module = await Test.createTestingModule({
controllers: [UserTokensController],
providers: [{ provide: UserTokensService, useValue: service }],
})
.overrideGuard(UserGuard)
.useValue({ canActivate: () => true })
.compile()

controller = module.get<UserTokensController>(UserTokensController)
})

it('delegates creation to the service with the body and the caller id', async () => {
const expirationDate = faker.date.soon({ days: 1, refDate: endOfToday().getTime() })
service.create.mockResolvedValueOnce({
token: {
id: faker.string.uuid(),
name: 'my-token',
lastUse: null,
expirationDate,
status: 'active' as const,
createdAt: new Date(),
userId: user.userId,
owner: { id: user.userId, email: faker.internet.email(), firstName: 'a', lastName: 'b', type: 'human' as const },
},
password: faker.string.alphanumeric(48),
})

await controller.create({ name: 'my-token', expirationDate }, user)

expect(service.create).toHaveBeenCalledWith({ name: 'my-token', expirationDate }, user.userId)
})

it('maps creation result to the exposed contract shape', async () => {
const expirationDate = faker.date.soon({ days: 1, refDate: endOfToday().getTime() })
const createdAt = faker.date.past()
const password = faker.string.alphanumeric(48)
service.create.mockResolvedValueOnce({
token: {
id: faker.string.uuid(),
name: 'my-token',
lastUse: null,
expirationDate,
status: 'active' as const,
createdAt,
userId: user.userId,
owner: { id: user.userId, email: faker.internet.email(), firstName: 'a', lastName: 'b', type: 'human' as const },
},
password,
})

const result = await controller.create({ name: 'my-token', expirationDate }, user)

expect(result).toEqual({
id: expect.any(String),
name: 'my-token',
lastUse: null,
expirationDate: expirationDate.toISOString(),
status: 'active',
createdAt: createdAt.toISOString(),
owner: expect.any(Object),
password,
})
expect('userId' in result).toBe(false)
})

it('maps listed records to the contract shape', async () => {
const record = {
id: faker.string.uuid(),
name: 'my-token',
lastUse: null,
expirationDate: faker.date.soon({ days: 1, refDate: endOfToday().getTime() }),
status: 'active' as const,
createdAt: faker.date.past(),
userId: user.userId,
owner: { id: user.userId, email: faker.internet.email(), firstName: 'a', lastName: 'b', type: 'human' as const },
}
service.list.mockResolvedValueOnce([record])

const result = await controller.list(user)

expect(result).toEqual([{
id: record.id,
name: record.name,
lastUse: null,
expirationDate: record.expirationDate.toISOString(),
status: record.status,
createdAt: record.createdAt.toISOString(),
owner: record.owner,
}])
})
})
Loading
Loading