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
1 change: 1 addition & 0 deletions .cursor/rules/api-rules.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ alwaysApply: false
* always run scripts from api/package.json unless requested
* prefer adding new files to the nest repo located at api/src/unraid-api/ instead of the legacy code
* Test suite is VITEST, do not use jest
pnpm --filter ./api test
* Prefer to not mock simple dependencies

9 changes: 9 additions & 0 deletions .cursor/rules/web-graphql.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
description:
globs: web/**/*
alwaysApply: false
---
* Always run `pnpm codegen` for GraphQL code generation in the web directory
* GraphQL queries must be placed in `.query.ts` files
* GraphQL mutations must be placed in `.mutation.ts` files
* All GraphQL under `web/` and follow this naming convention
2 changes: 1 addition & 1 deletion api/dev/states/myservers.cfg
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[api]
version="4.7.0"
version="4.8.0"
extraOrigins="https://google.com,https://test.com"
[local]
sandbox="yes"
Expand Down
88 changes: 55 additions & 33 deletions api/generated-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -883,6 +883,52 @@ type VmMutations {
reset(id: PrefixedID!): Boolean!
}

"""API Key related mutations"""
type ApiKeyMutations {
"""Create an API key"""
create(input: CreateApiKeyInput!): ApiKeyWithSecret!

"""Add a role to an API key"""
addRole(input: AddRoleForApiKeyInput!): Boolean!

"""Remove a role from an API key"""
removeRole(input: RemoveRoleFromApiKeyInput!): Boolean!

"""Delete one or more API keys"""
delete(input: DeleteApiKeyInput!): Boolean!
}

input CreateApiKeyInput {
name: String!
description: String
roles: [Role!]
permissions: [AddPermissionInput!]

"""
This will replace the existing key if one already exists with the same name, otherwise returns the existing key
"""
overwrite: Boolean
}

input AddPermissionInput {
resource: Resource!
actions: [String!]!
}

input AddRoleForApiKeyInput {
apiKeyId: PrefixedID!
role: Role!
}

input RemoveRoleFromApiKeyInput {
apiKeyId: PrefixedID!
role: Role!
}

input DeleteApiKeyInput {
ids: [PrefixedID!]!
}

"""
Parity check related mutations, WIP, response types and functionaliy will change
"""
Expand Down Expand Up @@ -1455,8 +1501,6 @@ type UserAccount implements Node {
scalar PrefixedID

type Query {
apiKeys: [ApiKey!]!
apiKey(id: PrefixedID!): ApiKey
cloud: Cloud!
config: Config!
display: Display!
Expand All @@ -1482,6 +1526,14 @@ type Query {
vms: Vms!
parityHistory: [ParityCheck!]!
array: UnraidArray!
apiKeys: [ApiKey!]!
apiKey(id: PrefixedID!): ApiKey

"""All possible roles for API keys"""
apiKeyPossibleRoles: [Role!]!

"""All possible permissions for API keys"""
apiKeyPossiblePermissions: [Permission!]!
connect: Connect!
remoteAccess: RemoteAccess!
extraAllowedOrigins: [String!]!
Expand All @@ -1496,10 +1548,6 @@ type Query {
}

type Mutation {
createApiKey(input: CreateApiKeyInput!): ApiKeyWithSecret!
addRoleForApiKey(input: AddRoleForApiKeyInput!): Boolean!
removeRoleFromApiKey(input: RemoveRoleFromApiKeyInput!): Boolean!

"""Creates a new notification record"""
createNotification(input: NotificationData!): Notification!
deleteNotification(id: PrefixedID!, type: NotificationType!): NotificationOverview!
Expand All @@ -1523,6 +1571,7 @@ type Mutation {
docker: DockerMutations!
vm: VmMutations!
parityCheck: ParityCheckMutations!
apiKey: ApiKeyMutations!
updateApiSettings(input: ApiSettingsInput!): ConnectSettingsValues!
connectSignIn(input: ConnectSignInInput!): Boolean!
connectSignOut: Boolean!
Expand All @@ -1532,33 +1581,6 @@ type Mutation {
setDemo: String!
}

input CreateApiKeyInput {
name: String!
description: String
roles: [Role!]
permissions: [AddPermissionInput!]

"""
This will replace the existing key if one already exists with the same name, otherwise returns the existing key
"""
overwrite: Boolean
}

input AddPermissionInput {
resource: Resource!
actions: [String!]!
}

input AddRoleForApiKeyInput {
apiKeyId: PrefixedID!
role: Role!
}

input RemoveRoleFromApiKeyInput {
apiKeyId: PrefixedID!
role: Role!
}

input NotificationData {
title: String!
subject: String!
Expand Down
2 changes: 1 addition & 1 deletion api/src/unraid-api/auth/api-key.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describe('ApiKeyService', () => {

await expect(
apiKeyService.create({ name: 'name', description: 'desc', roles: [] })
).rejects.toThrow('At least one role must be specified');
).rejects.toThrow('At least one role or permission must be specified');

await expect(
apiKeyService.create({
Expand Down
6 changes: 3 additions & 3 deletions api/src/unraid-api/auth/api-key.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,11 +130,11 @@ export class ApiKeyService implements OnModuleInit {
throw new GraphQLError('API key name is required');
}

if (!roles?.length) {
throw new GraphQLError('At least one role must be specified');
if (!roles?.length && !permissions?.length) {
throw new GraphQLError('At least one role or permission must be specified');
}

if (roles.some((role) => !ApiKeyService.validRoles.has(role))) {
if (roles?.some((role) => !ApiKeyService.validRoles.has(role))) {
throw new GraphQLError('Invalid role specified');
}

Expand Down
8 changes: 8 additions & 0 deletions api/src/unraid-api/graph/resolvers/api-key/api-key.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,11 @@ export class RemoveRoleFromApiKeyInput {
@IsEnum(Role)
role!: Role;
}

@InputType()
export class DeleteApiKeyInput {
@Field(() => [PrefixedID])
@IsArray()
@IsString({ each: true })
ids!: string[];
}
7 changes: 5 additions & 2 deletions api/src/unraid-api/graph/resolvers/api-key/api-key.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Module } from '@nestjs/common';

import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AuthModule } from '@app/unraid-api/auth/auth.module.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { ApiKeyMutationsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.mutation.js';
import { ApiKeyResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.resolver.js';

@Module({
providers: [ApiKeyResolver, ApiKeyService, AuthService],
exports: [ApiKeyResolver],
imports: [AuthModule],
providers: [ApiKeyResolver, ApiKeyService, AuthService, ApiKeyMutationsResolver],
exports: [ApiKeyResolver, ApiKeyService],
})
export class ApiKeyModule {}
165 changes: 165 additions & 0 deletions api/src/unraid-api/graph/resolvers/api-key/api-key.mutation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { newEnforcer } from 'casbin';
import { AuthZService } from 'nest-authz';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
import {
ApiKey,
ApiKeyWithSecret,
CreateApiKeyInput,
DeleteApiKeyInput,
} from '@app/unraid-api/graph/resolvers/api-key/api-key.model.js';
import { ApiKeyMutationsResolver } from '@app/unraid-api/graph/resolvers/api-key/api-key.mutation.js';
import { Role } from '@app/unraid-api/graph/resolvers/base.model.js';

describe('ApiKeyMutationsResolver', () => {
let resolver: ApiKeyMutationsResolver;
let authService: AuthService;
let apiKeyService: ApiKeyService;
let authzService: AuthZService;
let cookieService: CookieService;

const mockApiKey: ApiKey = {
id: 'test-api-id',
name: 'Test API Key',
description: 'Test API Key Description',
roles: [Role.GUEST],
createdAt: new Date().toISOString(),
permissions: [],
};

const mockApiKeyWithSecret: ApiKeyWithSecret = {
id: 'test-api-id',
key: 'test-api-key',
name: 'Test API Key',
description: 'Test API Key Description',
roles: [Role.GUEST],
createdAt: new Date().toISOString(),
permissions: [],
};

beforeEach(async () => {
vi.resetAllMocks();

const enforcer = await newEnforcer();

apiKeyService = new ApiKeyService();
authzService = new AuthZService(enforcer);
cookieService = new CookieService();
authService = new AuthService(cookieService, apiKeyService, authzService);
resolver = new ApiKeyMutationsResolver(authService, apiKeyService);
});

describe('create', () => {
it('should create new API key and sync roles', async () => {
const input: CreateApiKeyInput = {
name: 'New API Key',
description: 'New API Key Description',
roles: [Role.GUEST],
permissions: [],
};

vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
vi.spyOn(authService, 'syncApiKeyRoles').mockResolvedValue();

const result = await resolver.create(input);

expect(result).toEqual(mockApiKeyWithSecret);
expect(apiKeyService.create).toHaveBeenCalledWith({
name: input.name,
description: input.description,
overwrite: false,
roles: input.roles,
permissions: [],
});
expect(authService.syncApiKeyRoles).toHaveBeenCalledWith(mockApiKey.id, mockApiKey.roles);
});

it('should throw if API key creation fails', async () => {
const input: CreateApiKeyInput = {
name: 'Failing API Key',
description: 'Should fail',
roles: [Role.GUEST],
permissions: [],
};
vi.spyOn(apiKeyService, 'create').mockRejectedValue(new Error('Create failed'));
await expect(resolver.create(input)).rejects.toThrow('Create failed');
});

it('should throw if role synchronization fails', async () => {
const input: CreateApiKeyInput = {
name: 'Sync Fail API Key',
description: 'Should fail sync',
roles: [Role.GUEST],
permissions: [],
};
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockApiKeyWithSecret);
vi.spyOn(authService, 'syncApiKeyRoles').mockRejectedValue(new Error('Sync failed'));
await expect(resolver.create(input)).rejects.toThrow('Sync failed');
});

it('should throw if input validation fails (empty name)', async () => {
const input: CreateApiKeyInput = {
name: '',
description: 'No name',
roles: [Role.GUEST],
permissions: [],
};
await expect(resolver.create(input)).rejects.toThrow();
});
});

describe('delete', () => {
it('should delete API keys', async () => {
const input: DeleteApiKeyInput = { ids: [mockApiKey.id] };
vi.spyOn(apiKeyService, 'deleteApiKeys').mockResolvedValue();

const result = await resolver.delete(input);

expect(result).toBe(true);
expect(apiKeyService.deleteApiKeys).toHaveBeenCalledWith(input.ids);
});
});

describe('addRole', () => {
it('should add a role to an API key', async () => {
const input = { apiKeyId: mockApiKey.id, role: Role.ADMIN };
vi.spyOn(authService, 'addRoleToApiKey').mockResolvedValue(true);

const result = await resolver.addRole(input);

expect(result).toBe(true);
expect(authService.addRoleToApiKey).toHaveBeenCalledWith(input.apiKeyId, input.role);
});

it('should throw if addRoleToApiKey throws', async () => {
const input = { apiKeyId: 'bad-id', role: Role.ADMIN };
vi.spyOn(authService, 'addRoleToApiKey').mockRejectedValue(new Error('API key not found'));

await expect(resolver.addRole(input)).rejects.toThrow('API key not found');
});
});

describe('removeRole', () => {
it('should remove a role from an API key', async () => {
const input = { apiKeyId: mockApiKey.id, role: Role.GUEST };
vi.spyOn(authService, 'removeRoleFromApiKey').mockResolvedValue(true);

const result = await resolver.removeRole(input);

expect(result).toBe(true);
expect(authService.removeRoleFromApiKey).toHaveBeenCalledWith(input.apiKeyId, input.role);
});

it('should throw if removeRoleFromApiKey throws', async () => {
const input = { apiKeyId: 'bad-id', role: Role.GUEST };
vi.spyOn(authService, 'removeRoleFromApiKey').mockRejectedValue(
new Error('API key not found')
);

await expect(resolver.removeRole(input)).rejects.toThrow('API key not found');
});
});
});
Loading
Loading