fix: api key creation cli (#1637)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
  - CLI now prompts for roles/permissions only when not provided.
- Bug Fixes
  - Existing API keys are detected by name during overwrite checks.
  - Invalid role inputs are filtered out with clear warnings.
- Refactor
  - Centralized role parsing/validation with improved error messages.
- CLI create flow prompts only when minimum info is missing and uses a
sensible default description.
- Tests
- Added comprehensive unit tests for role parsing and CLI flows (create,
retrieve, overwrite).
- Chores
  - Updated API configuration version to 4.17.0.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Eli Bosley
2025-08-29 10:49:31 -04:00
committed by GitHub
parent 9d42b36f74
commit c147a6b507
7 changed files with 646 additions and 20 deletions

View File

@@ -1,5 +1,5 @@
{
"version": "4.15.1",
"version": "4.17.0",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -681,4 +681,104 @@ describe('ApiKeyService', () => {
]);
});
});
describe('convertRolesStringArrayToRoles', () => {
beforeEach(async () => {
vi.mocked(getters.paths).mockReturnValue({
'auth-keys': mockBasePath,
} as ReturnType<typeof getters.paths>);
// Create a fresh mock logger for each test
mockLogger = {
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
verbose: vi.fn(),
};
apiKeyService = new ApiKeyService();
// Replace the logger with our mock
(apiKeyService as any).logger = mockLogger;
});
it('should convert uppercase role strings to Role enum values', () => {
const roles = ['ADMIN', 'CONNECT', 'VIEWER'];
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.VIEWER]);
});
it('should convert lowercase role strings to Role enum values', () => {
const roles = ['admin', 'connect', 'guest'];
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.GUEST]);
});
it('should convert mixed case role strings to Role enum values', () => {
const roles = ['Admin', 'CoNnEcT', 'ViEwEr'];
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.VIEWER]);
});
it('should handle roles with whitespace', () => {
const roles = [' ADMIN ', ' CONNECT ', 'VIEWER '];
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.VIEWER]);
});
it('should filter out invalid roles and warn', () => {
const roles = ['ADMIN', 'INVALID_ROLE', 'VIEWER', 'ANOTHER_INVALID'];
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
expect(result).toEqual([Role.ADMIN, Role.VIEWER]);
expect(mockLogger.warn).toHaveBeenCalledWith(
'Ignoring invalid roles: INVALID_ROLE, ANOTHER_INVALID'
);
});
it('should return empty array when all roles are invalid', () => {
const roles = ['INVALID1', 'INVALID2', 'INVALID3'];
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
expect(result).toEqual([]);
expect(mockLogger.warn).toHaveBeenCalledWith(
'Ignoring invalid roles: INVALID1, INVALID2, INVALID3'
);
});
it('should return empty array for empty input', () => {
const result = apiKeyService.convertRolesStringArrayToRoles([]);
expect(result).toEqual([]);
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should handle all valid Role enum values', () => {
const roles = Object.values(Role);
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
expect(result).toEqual(Object.values(Role));
expect(mockLogger.warn).not.toHaveBeenCalled();
});
it('should deduplicate roles', () => {
const roles = ['ADMIN', 'admin', 'ADMIN', 'VIEWER', 'viewer'];
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
// Note: Current implementation doesn't deduplicate, but this test documents the behavior
expect(result).toEqual([Role.ADMIN, Role.ADMIN, Role.ADMIN, Role.VIEWER, Role.VIEWER]);
});
it('should handle mixed valid and invalid roles correctly', () => {
const roles = ['ADMIN', 'invalid', 'CONNECT', 'bad_role', 'GUEST', 'VIEWER'];
const result = apiKeyService.convertRolesStringArrayToRoles(roles);
expect(result).toEqual([Role.ADMIN, Role.CONNECT, Role.GUEST, Role.VIEWER]);
expect(mockLogger.warn).toHaveBeenCalledWith('Ignoring invalid roles: invalid, bad_role');
});
});
});

View File

@@ -110,9 +110,25 @@ export class ApiKeyService implements OnModuleInit {
}
public convertRolesStringArrayToRoles(roles: string[]): Role[] {
return roles
.map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role])
.filter(Boolean);
const validRoles: Role[] = [];
const invalidRoles: string[] = [];
for (const roleStr of roles) {
const upperRole = roleStr.trim().toUpperCase();
const role = Role[upperRole as keyof typeof Role];
if (role && ApiKeyService.validRoles.has(role)) {
validRoles.push(role);
} else {
invalidRoles.push(roleStr);
}
}
if (invalidRoles.length > 0) {
this.logger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`);
}
return validRoles;
}
async create({

View File

@@ -0,0 +1,192 @@
import { Test, TestingModule } from '@nestjs/testing';
import { InquirerService } from 'nest-commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AddApiKeyQuestionSet } from '@app/unraid-api/cli/apikey/add-api-key.questions.js';
import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
describe('ApiKeyCommand', () => {
let command: ApiKeyCommand;
let apiKeyService: ApiKeyService;
let logService: LogService;
let inquirerService: InquirerService;
let questionSet: AddApiKeyQuestionSet;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeyCommand,
AddApiKeyQuestionSet,
{
provide: ApiKeyService,
useValue: {
findByField: vi.fn(),
create: vi.fn(),
findAll: vi.fn(),
deleteApiKeys: vi.fn(),
convertRolesStringArrayToRoles: vi.fn((roles) => roles),
convertPermissionsStringArrayToPermissions: vi.fn((perms) => perms),
getAllValidPermissions: vi.fn(() => []),
},
},
{
provide: LogService,
useValue: {
log: vi.fn(),
error: vi.fn(),
},
},
{
provide: InquirerService,
useValue: {
prompt: vi.fn(),
},
},
],
}).compile();
command = module.get<ApiKeyCommand>(ApiKeyCommand);
apiKeyService = module.get<ApiKeyService>(ApiKeyService);
logService = module.get<LogService>(LogService);
inquirerService = module.get<InquirerService>(InquirerService);
questionSet = module.get<AddApiKeyQuestionSet>(AddApiKeyQuestionSet);
});
describe('AddApiKeyQuestionSet', () => {
describe('shouldAskOverwrite', () => {
it('should return true when an API key with the given name exists', () => {
vi.mocked(apiKeyService.findByField).mockReturnValue({
key: 'existing-key',
name: 'test-key',
description: 'Test key',
roles: [],
permissions: [],
} as any);
const result = questionSet.shouldAskOverwrite({ name: 'test-key' });
expect(result).toBe(true);
expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'test-key');
});
it('should return false when no API key with the given name exists', () => {
vi.mocked(apiKeyService.findByField).mockReturnValue(null);
const result = questionSet.shouldAskOverwrite({ name: 'non-existent-key' });
expect(result).toBe(false);
expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'non-existent-key');
});
});
});
describe('run', () => {
it('should find and return existing key when not creating', async () => {
const mockKey = { key: 'test-api-key-123', name: 'test-key' };
vi.mocked(apiKeyService.findByField).mockReturnValue(mockKey as any);
await command.run([], { name: 'test-key', create: false });
expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'test-key');
expect(logService.log).toHaveBeenCalledWith('test-api-key-123');
});
it('should create new key when key does not exist and create flag is set', async () => {
vi.mocked(apiKeyService.findByField).mockReturnValue(null);
vi.mocked(apiKeyService.create).mockResolvedValue({ key: 'new-api-key-456' } as any);
await command.run([], {
name: 'new-key',
create: true,
roles: ['ADMIN'] as any,
description: 'Test description',
});
expect(apiKeyService.create).toHaveBeenCalledWith({
name: 'new-key',
description: 'Test description',
roles: ['ADMIN'],
permissions: undefined,
overwrite: false,
});
expect(logService.log).toHaveBeenCalledWith('new-api-key-456');
});
it('should error when key exists and overwrite is not set in non-interactive mode', async () => {
const mockKey = { key: 'existing-key', name: 'test-key' };
vi.mocked(apiKeyService.findByField)
.mockReturnValueOnce(null) // First call in line 131
.mockReturnValueOnce(mockKey as any); // Second call in non-interactive check
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit');
});
await expect(
command.run([], {
name: 'test-key',
create: true,
roles: ['ADMIN'] as any,
})
).rejects.toThrow();
expect(logService.error).toHaveBeenCalledWith(
"API key with name 'test-key' already exists. Use --overwrite to replace it."
);
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
});
it('should create key with overwrite when key exists and overwrite is set', async () => {
const mockKey = { key: 'existing-key', name: 'test-key' };
vi.mocked(apiKeyService.findByField)
.mockReturnValueOnce(null) // First call in line 131
.mockReturnValueOnce(mockKey as any); // Second call in non-interactive check
vi.mocked(apiKeyService.create).mockResolvedValue({ key: 'overwritten-key' } as any);
await command.run([], {
name: 'test-key',
create: true,
roles: ['ADMIN'] as any,
overwrite: true,
});
expect(apiKeyService.create).toHaveBeenCalledWith({
name: 'test-key',
description: 'CLI generated key: test-key',
roles: ['ADMIN'],
permissions: undefined,
overwrite: true,
});
expect(logService.log).toHaveBeenCalledWith('overwritten-key');
});
it('should prompt for missing fields when creating without sufficient info', async () => {
vi.mocked(apiKeyService.findByField).mockReturnValue(null);
vi.mocked(inquirerService.prompt).mockResolvedValue({
name: 'prompted-key',
roles: ['USER'],
permissions: [],
description: 'Prompted description',
overwrite: false,
} as any);
vi.mocked(apiKeyService.create).mockResolvedValue({ key: 'prompted-api-key' } as any);
await command.run([], { name: '', create: true });
expect(inquirerService.prompt).toHaveBeenCalledWith('add-api-key', {
name: '',
create: true,
});
expect(apiKeyService.create).toHaveBeenCalledWith({
name: 'prompted-key',
description: 'Prompted description',
roles: ['USER'],
permissions: [],
overwrite: false,
});
});
});
});

View File

@@ -39,6 +39,12 @@ export class AddApiKeyQuestionSet {
return this.apiKeyService.convertRolesStringArrayToRoles(val);
}
@WhenFor({ name: 'roles' })
shouldAskRoles(options: { roles?: Role[]; permissions?: Permission[] }): boolean {
// Ask for roles if they weren't provided or are empty
return !options.roles || options.roles.length === 0;
}
@ChoicesFor({ name: 'roles' })
async getRoles() {
return Object.values(Role);
@@ -53,6 +59,12 @@ export class AddApiKeyQuestionSet {
return this.apiKeyService.convertPermissionsStringArrayToPermissions(val);
}
@WhenFor({ name: 'permissions' })
shouldAskPermissions(options: { roles?: Role[]; permissions?: Permission[] }): boolean {
// Ask for permissions if they weren't provided or are empty
return !options.permissions || options.permissions.length === 0;
}
@ChoicesFor({ name: 'permissions' })
async getPermissions() {
return this.apiKeyService
@@ -72,6 +84,6 @@ export class AddApiKeyQuestionSet {
@WhenFor({ name: 'overwrite' })
shouldAskOverwrite(options: { name: string }): boolean {
return Boolean(this.apiKeyService.findByKey(options.name));
return Boolean(this.apiKeyService.findByField('name', options.name));
}
}

View File

@@ -0,0 +1,285 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthAction, Resource, Role } from '@unraid/shared/graphql.model.js';
import { InquirerService } from 'nest-commander';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { ApiKeyCommand } from '@app/unraid-api/cli/apikey/api-key.command.js';
import { LogService } from '@app/unraid-api/cli/log.service.js';
describe('ApiKeyCommand', () => {
let command: ApiKeyCommand;
let apiKeyService: ApiKeyService;
let logService: LogService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ApiKeyCommand,
{
provide: ApiKeyService,
useValue: {
findByField: vi.fn(),
create: vi.fn(),
convertRolesStringArrayToRoles: vi.fn(),
convertPermissionsStringArrayToPermissions: vi.fn(),
findAll: vi.fn(),
deleteApiKeys: vi.fn(),
},
},
{
provide: LogService,
useValue: {
log: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
},
},
{
provide: InquirerService,
useValue: {
prompt: vi.fn(),
},
},
],
}).compile();
command = module.get<ApiKeyCommand>(ApiKeyCommand);
apiKeyService = module.get<ApiKeyService>(ApiKeyService);
logService = module.get<LogService>(LogService);
});
describe('parseRoles', () => {
it('should parse valid roles correctly', () => {
const mockConvert = vi
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
.mockReturnValue([Role.ADMIN, Role.CONNECT]);
const result = command.parseRoles('ADMIN,CONNECT');
expect(mockConvert).toHaveBeenCalledWith(['ADMIN', 'CONNECT']);
expect(result).toEqual([Role.ADMIN, Role.CONNECT]);
});
it('should return GUEST role when no roles provided', () => {
const result = command.parseRoles('');
expect(result).toEqual([Role.GUEST]);
});
it('should handle roles with spaces', () => {
const mockConvert = vi
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
.mockReturnValue([Role.ADMIN, Role.VIEWER]);
const result = command.parseRoles('ADMIN, VIEWER');
expect(mockConvert).toHaveBeenCalledWith(['ADMIN', ' VIEWER']);
expect(result).toEqual([Role.ADMIN, Role.VIEWER]);
});
it('should throw error when no valid roles found', () => {
vi.spyOn(apiKeyService, 'convertRolesStringArrayToRoles').mockReturnValue([]);
expect(() => command.parseRoles('INVALID_ROLE')).toThrow(
`Invalid roles. Valid options are: ${Object.values(Role).join(', ')}`
);
});
it('should handle mixed valid and invalid roles with warning', () => {
const mockConvert = vi
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
.mockImplementation((roles) => {
const validRoles: Role[] = [];
const invalidRoles: string[] = [];
for (const roleStr of roles) {
const upperRole = roleStr.trim().toUpperCase();
const role = Role[upperRole as keyof typeof Role];
if (role) {
validRoles.push(role);
} else {
invalidRoles.push(roleStr);
}
}
if (invalidRoles.length > 0) {
logService.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`);
}
return validRoles;
});
const result = command.parseRoles('ADMIN,INVALID,VIEWER');
expect(mockConvert).toHaveBeenCalledWith(['ADMIN', 'INVALID', 'VIEWER']);
expect(logService.warn).toHaveBeenCalledWith('Ignoring invalid roles: INVALID');
expect(result).toEqual([Role.ADMIN, Role.VIEWER]);
});
});
describe('run', () => {
it('should create API key with roles without prompting', async () => {
const mockKey = {
id: 'test-id',
key: 'test-key-123',
name: 'TEST',
roles: [Role.ADMIN],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
await command.run([], {
name: 'TEST',
create: true,
roles: [Role.ADMIN],
permissions: undefined,
description: 'Test description',
});
expect(apiKeyService.create).toHaveBeenCalledWith({
name: 'TEST',
description: 'Test description',
roles: [Role.ADMIN],
permissions: undefined,
overwrite: false,
});
expect(logService.log).toHaveBeenCalledWith('test-key-123');
});
it('should create API key with permissions only without prompting', async () => {
const mockKey = {
id: 'test-id',
key: 'test-key-456',
name: 'TEST_PERMS',
roles: [],
createdAt: new Date().toISOString(),
permissions: [],
};
const mockPermissions = [
{
resource: Resource.DOCKER,
actions: [AuthAction.READ_ANY],
},
];
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
await command.run([], {
name: 'TEST_PERMS',
create: true,
roles: undefined,
permissions: mockPermissions,
description: 'Test with permissions',
});
expect(apiKeyService.create).toHaveBeenCalledWith({
name: 'TEST_PERMS',
description: 'Test with permissions',
roles: undefined,
permissions: mockPermissions,
overwrite: false,
});
expect(logService.log).toHaveBeenCalledWith('test-key-456');
});
it('should use default description when not provided', async () => {
const mockKey = {
id: 'test-id',
key: 'test-key-789',
name: 'NO_DESC',
roles: [Role.VIEWER],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(null);
vi.spyOn(apiKeyService, 'create').mockResolvedValue(mockKey);
await command.run([], {
name: 'NO_DESC',
create: true,
roles: [Role.VIEWER],
permissions: undefined,
});
expect(apiKeyService.create).toHaveBeenCalledWith({
name: 'NO_DESC',
description: 'CLI generated key: NO_DESC',
roles: [Role.VIEWER],
permissions: undefined,
overwrite: false,
});
});
it('should return existing key when found', async () => {
const existingKey = {
id: 'existing-id',
key: 'existing-key-123',
name: 'EXISTING',
roles: [Role.ADMIN],
createdAt: new Date().toISOString(),
permissions: [],
};
vi.spyOn(apiKeyService, 'findByField').mockReturnValue(existingKey);
await command.run([], {
name: 'EXISTING',
create: false,
});
expect(apiKeyService.findByField).toHaveBeenCalledWith('name', 'EXISTING');
expect(logService.log).toHaveBeenCalledWith('existing-key-123');
expect(apiKeyService.create).not.toHaveBeenCalled();
});
it('should handle uppercase role conversion', () => {
const mockConvert = vi
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
.mockImplementation((roles) => {
return roles
.map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role])
.filter(Boolean);
});
const result = command.parseRoles('admin,connect');
expect(mockConvert).toHaveBeenCalledWith(['admin', 'connect']);
expect(result).toEqual([Role.ADMIN, Role.CONNECT]);
});
it('should handle lowercase role conversion', () => {
const mockConvert = vi
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
.mockImplementation((roles) => {
return roles
.map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role])
.filter(Boolean);
});
const result = command.parseRoles('viewer');
expect(mockConvert).toHaveBeenCalledWith(['viewer']);
expect(result).toEqual([Role.VIEWER]);
});
it('should handle mixed case role conversion', () => {
const mockConvert = vi
.spyOn(apiKeyService, 'convertRolesStringArrayToRoles')
.mockImplementation((roles) => {
return roles
.map((roleStr) => Role[roleStr.trim().toUpperCase() as keyof typeof Role])
.filter(Boolean);
});
const result = command.parseRoles('Admin,CoNnEcT');
expect(mockConvert).toHaveBeenCalledWith(['Admin', 'CoNnEcT']);
expect(result).toEqual([Role.ADMIN, Role.CONNECT]);
});
});
});

View File

@@ -15,6 +15,7 @@ interface KeyOptions {
description?: string;
roles?: Role[];
permissions?: Permission[];
overwrite?: boolean;
}
@Command({
@@ -52,22 +53,15 @@ export class ApiKeyCommand extends CommandRunner {
})
parseRoles(roles: string): Role[] {
if (!roles) return [Role.GUEST];
const validRoles: Set<Role> = new Set(Object.values(Role));
const requestedRoles = roles.split(',').map((role) => role.trim().toLocaleLowerCase() as Role);
const validRequestedRoles = requestedRoles.filter((role) => validRoles.has(role));
const roleArray = roles.split(',').filter(Boolean);
const validRoles = this.apiKeyService.convertRolesStringArrayToRoles(roleArray);
if (validRequestedRoles.length === 0) {
throw new Error(`Invalid roles. Valid options are: ${Array.from(validRoles).join(', ')}`);
if (validRoles.length === 0) {
throw new Error(`Invalid roles. Valid options are: ${Object.values(Role).join(', ')}`);
}
const invalidRoles = requestedRoles.filter((role) => !validRoles.has(role));
if (invalidRoles.length > 0) {
this.logger.warn(`Ignoring invalid roles: ${invalidRoles.join(', ')}`);
}
return validRequestedRoles;
return validRoles;
}
@Option({
@@ -98,6 +92,14 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
return true;
}
@Option({
flags: '--overwrite',
description: 'Overwrite existing API key if it exists',
})
parseOverwrite(): boolean {
return true;
}
/** Prompt the user to select API keys to delete. Then, delete the selected keys. */
private async deleteKeys() {
const allKeys = await this.apiKeyService.findAll();
@@ -138,8 +140,27 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
if (key) {
this.logger.log(key.key);
} else if (options.create) {
options = await this.inquirerService.prompt(AddApiKeyQuestionSet.name, options);
this.logger.log('Creating API Key...' + JSON.stringify(options));
// Check if we have minimum required info from flags (name + at least one role or permission)
const hasMinimumInfo =
options.name &&
((options.roles && options.roles.length > 0) ||
(options.permissions && options.permissions.length > 0));
if (!hasMinimumInfo) {
// Interactive mode - prompt for missing fields
options = await this.inquirerService.prompt(AddApiKeyQuestionSet.name, options);
} else {
// Non-interactive mode - check if key exists and handle overwrite
const existingKey = this.apiKeyService.findByField('name', options.name);
if (existingKey && !options.overwrite) {
this.logger.error(
`API key with name '${options.name}' already exists. Use --overwrite to replace it.`
);
process.exit(1);
}
}
this.logger.log('Creating API Key...');
if (!options.roles && !options.permissions) {
this.logger.error('Please add at least one role or permission to the key.');
@@ -154,7 +175,7 @@ ACTIONS: ${Object.values(AuthAction).join(', ')}`,
description: options.description || `CLI generated key: ${options.name}`,
roles: options.roles,
permissions: options.permissions,
overwrite: true,
overwrite: options.overwrite ?? false,
});
this.logger.log(key.key);