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
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1 +1 @@
pnpm exec lint-staged
npm test
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we also want to run lint-staged no?

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"test:integration": "cross-env DATABASE_LOCATION=:memory: vitest run --project integration",
"test:e2e": "playwright test",
"---- DB ------------------------------------------------------------": "",
"setupdb": "pnpm migrate && pnpm db-types",
"setupdb": "pnpm migrate:latest && pnpm db-types",
"migrate": "tsx ./services/src/kysely/migrator.ts",
"migrate:latest": "pnpm run migrate -- latest",
"migrate:up": "pnpm run migrate -- up",
Expand All @@ -43,6 +43,7 @@
"tailwind-merge": "^2.3.0",
"tailwind-variants": "^0.2.1",
"typesafe-utils": "^1.16.2",
"uuid": "^9.0.1",
"zod": "^3.23.5"
},
"devDependencies": {
Expand All @@ -58,6 +59,7 @@
"@types/jsonwebtoken": "^9.0.6",
"@types/minimist": "^1.2.5",
"@types/node": "^20.12.7",
"@types/uuid": "^9.0.8",
"@typescript-eslint/eslint-plugin": "^7.6.0",
"@typescript-eslint/parser": "^7.6.0",
"autoprefixer": "^10.4.19",
Expand Down
36 changes: 18 additions & 18 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions services/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
"kysely": "^0.27.3",
"pino": "^9.0.0",
"pino-pretty": "^11.0.0",
"uuid": "^9.0.1",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since node v20 the std. crypto library has builtin uuid v4 generator. Let's not add the dependency here.

"zod": "^3.23.5"
},
"devDependencies": {
"@types/uuid": "^9.0.8",
"vite": "^5.2.11",
"vitest": "^1.5.3"
},
Expand Down
134 changes: 134 additions & 0 deletions services/src/auth/api-access-repository.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { db } from '../db/database'
import { runMigration } from '../db/database-migration-util'
import { createProject } from '../project/project.repository'
import {
createApiAccess,
deleteApiAccess,
getApiAccessForProject,
projectHasKey,
setApiAccessName
} from './api-access.repository'
import type { ProjectId } from '../project/project'

beforeEach(async () => {
db.reset()
await runMigration()
})

describe('ApiKey Repository', () => {
let projectId: ProjectId
beforeEach(async () => {
projectId = (await createProject({ name: 'demo project name' })).id as ProjectId
})

describe('createApiAccess', () => {
it('should create an api key given an existing project', async () => {
const result = await createApiAccess(projectId)
expect(result).toBeTruthy()
expect(false).toBeFalsy()
})
})

describe('getApiAccessForProject', () => {
it('should fetch all apiKeys for a given project', async () => {
const access1 = await createApiAccess(projectId)
const access2 = await createApiAccess(projectId)

const keys = (await getApiAccessForProject(projectId)).map((it) => it.apikey)
expect(keys).toHaveLength(2)
expect(keys).includes(access1.apikey)
expect(keys).includes(access2.apikey)
})

it('should retrieve the name of the apiKey when it is created with one', async () => {
const name = 'some key name'
const key = await createApiAccess(projectId, name)
const result = await getApiAccessForProject(projectId)

expect(result.find((it) => it.apikey === key.apikey)?.name).toBe(name)
})

it('should no longer find deleted keys', async () => {
const key = await createApiAccess(projectId)

const keysBeforeDelete = await getApiAccessForProject(projectId)
expect(keysBeforeDelete.map((it) => it.apikey)).toContain(key.apikey)

await deleteApiAccess(projectId, key.apikey)
const keysAfterDelete = await getApiAccessForProject(projectId)
expect(keysAfterDelete.map((it) => it.apikey).includes(key.apikey)).toBe(false)
})

it('should return an empty list in case the project does not exist', async () => {
const keys = await getApiAccessForProject(projectId)
expect(keys).toHaveLength(0)
})

it('should return an empty list if there are no keys', async () => {
const result = await getApiAccessForProject(projectId)
expect(result).toHaveLength(0)
})
})

describe('setApiAccessName', () => {
it('should be able to set a name when previously there was none', async () => {
const key = await createApiAccess(projectId)
const initialRetrieval = await getApiAccessForProject(projectId).then((it) =>
it.find((apiKey) => apiKey.apikey === key.apikey)
)

expect(initialRetrieval?.name).toBeFalsy()
const updatedName = 'some new apiKeyName'
await setApiAccessName(key.id, updatedName)

const secondRetrieval = await getApiAccessForProject(projectId).then((it) =>
it.find((apiKey) => apiKey.apikey === key.apikey)
)
expect(secondRetrieval?.name).toBe(updatedName)
})

it('should be able to set the name', async () => {
const initialName = 'my personal api key'
const key = await createApiAccess(projectId, initialName)
const initialRetrieval = await getApiAccessForProject(projectId).then((it) =>
it.find((apiKey) => apiKey.apikey === key.apikey)
)

expect(initialRetrieval?.name).toBe(initialName)
const updatedName = 'some new apiKeyName'
await setApiAccessName(key.id, updatedName)

const secondRetrieval = await getApiAccessForProject(projectId).then((it) =>
it.find((apiKey) => apiKey.apikey === key.apikey)
)
expect(secondRetrieval?.name).toBe(updatedName)
})
})

describe('projectHasKey', () => {
it('should return true if there is a key for the project', async () => {
const key = await createApiAccess(projectId)
const result = await projectHasKey(projectId, key.apikey)
expect(result).toBe(true)
})

it('should return false if the project does not have the corresponding key', async () => {
const result = await projectHasKey(projectId, 'nonexiststant-id')
expect(result).toBe(false)
})

it('should return false if the project does not exist', async () => {
const result = await projectHasKey(4242, 'nonexiststant-id')
expect(result).toBe(false)
})

it('should return false if key and project exist, but do not match', async () => {
const otherProjectId = (await createProject({ name: 'another Project' })).id as ProjectId
const key = await createApiAccess(projectId)

const result = await projectHasKey(otherProjectId, key.apikey)
expect(result).toBe(false)
})
})
})
53 changes: 53 additions & 0 deletions services/src/auth/api-access.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { db } from '../db/database'
import type { ApiKeyCreationParams, SelectableApiKey } from './api-access'
import { v4 as uuid } from 'uuid'

export function createApiAccess(
projectId: number,
name: string | null = null
): Promise<SelectableApiKey> {
const insertKey: ApiKeyCreationParams = {
apikey: uuid(),
name,
project_id: projectId
}

return db
.insertInto('apiaccess')
.values(insertKey)
.returningAll()
.executeTakeFirstOrThrow(() => new Error('Error creating Api Access Key'))
}

export function getApiAccessForProject(projectId: number): Promise<SelectableApiKey[]> {
return db.selectFrom('apiaccess').selectAll().where('project_id', '==', projectId).execute()
}

export async function setApiAccessName(keyId: number, name: string): Promise<void> {
await db
.updateTable('apiaccess')
.set({
name
})
.where('id', '==', keyId)
.execute()
}

export async function projectHasKey(projectId: number, apikey: string): Promise<boolean> {
const result = await db
.selectFrom('apiaccess')
.selectAll()
.where('project_id', '==', projectId)
.where('apikey', '==', apikey)
.execute()

return !!result.length
}

export async function deleteApiAccess(projectId: number, apikey: string): Promise<void> {
await db
.deleteFrom('apiaccess')
.where('project_id', '==', projectId)
.where('apikey', '==', apikey)
.execute()
}
27 changes: 27 additions & 0 deletions services/src/auth/api-access.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ProjectId } from '../project/project'
import { type ApiAccess, type ApiAccessId, type ApiKey, apiAccessSchema } from './api-access'
import {
createApiAccess,
getApiAccessForProject,
projectHasKey,
setApiAccessName
} from './api-access.repository'

export const checkApiKeyAccess = async (apiKey: ApiKey, projectId: ProjectId): Promise<boolean> =>
projectHasKey(projectId, apiKey)

export const addApiAccess = async (projectId: ProjectId): Promise<ApiAccess> => {
const key = await createApiAccess(projectId)

return apiAccessSchema.parse(key)
}

export const changeApiAccessName = async (apiAccessId: ApiAccessId, name: string) => {
await setApiAccessName(apiAccessId, name)
}

export const listApiAccessForProject = async (projectId: ProjectId): Promise<ApiAccess[]> => {
const queryResult = await getApiAccessForProject(projectId)

return apiAccessSchema.array().parse(queryResult)
}
22 changes: 22 additions & 0 deletions services/src/auth/api-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Insertable, Selectable } from 'kysely'
import type { Apiaccess } from 'kysely-codegen'
import { z } from 'zod'

export type ApiKeyCreationParams = Insertable<Omit<Apiaccess, 'id' | 'created_at'>>
export type SelectableApiKey = Selectable<Apiaccess>

const apiAccessIdSchema = z.number().brand('api-access')
export type ApiAccessId = z.infer<typeof apiAccessIdSchema>

export const apiKeySchema = z.string().uuid().brand('api-key')
export type ApiKey = z.infer<typeof apiKeySchema>

export const apiAccessSchema = z.object({
id: apiAccessIdSchema,
apikey: apiKeySchema,
name: z.string(),
project_id: z.number(),
created_at: z.date(),
updated_at: z.date()
})
export type ApiAccess = z.infer<typeof apiAccessSchema>
Loading