mirror of
https://github.com/unraid/api.git
synced 2026-01-01 06:01:18 -06:00
feat: remove Unraid API log download functionality (#1793)
## Summary - remove the REST API log download helper and associated service wiring - drop the Download API Logs UI component and related registrations and test references - update tests and type declarations to reflect the removal ## Testing - Not run (not requested) ------ [Codex Task](https://chatgpt.com/codex/tasks/task_e_691ce360f8f88323888ad6ef49f32b45) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Removed Features** * Removed the API logs download feature — the UI download component and the corresponding public API endpoint are no longer available. * **Chores** * Cleaned up related tests, component registrations, and unused integration/dependency wiring tied to the removed logs feature. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { CacheModule } from '@nestjs/cache-manager';
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
@@ -10,7 +11,11 @@ describe('Module Dependencies Integration', () => {
|
|||||||
let module;
|
let module;
|
||||||
try {
|
try {
|
||||||
module = await Test.createTestingModule({
|
module = await Test.createTestingModule({
|
||||||
imports: [CacheModule.register({ isGlobal: true }), RestModule],
|
imports: [
|
||||||
|
ConfigModule.forRoot({ ignoreEnvFile: true, isGlobal: true }),
|
||||||
|
CacheModule.register({ isGlobal: true }),
|
||||||
|
RestModule,
|
||||||
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
expect(module).toBeDefined();
|
expect(module).toBeDefined();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { forwardRef, Module } from '@nestjs/common';
|
import { forwardRef, Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
|
||||||
import { UserSettingsModule } from '@unraid/shared/services/user-settings.js';
|
import { UserSettingsModule } from '@unraid/shared/services/user-settings.js';
|
||||||
|
|
||||||
@@ -7,7 +8,7 @@ import { OidcConfigPersistence } from '@app/unraid-api/graph/resolvers/sso/core/
|
|||||||
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
import { OidcValidationService } from '@app/unraid-api/graph/resolvers/sso/core/oidc-validation.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UserSettingsModule, forwardRef(() => OidcClientModule)],
|
imports: [ConfigModule, UserSettingsModule, forwardRef(() => OidcClientModule)],
|
||||||
providers: [OidcConfigPersistence, OidcValidationService],
|
providers: [OidcConfigPersistence, OidcValidationService],
|
||||||
exports: [OidcConfigPersistence, OidcValidationService],
|
exports: [OidcConfigPersistence, OidcValidationService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
|
||||||
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
|
||||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
|
||||||
|
|
||||||
// Mock external dependencies
|
|
||||||
vi.mock('@app/store/index.js', () => ({
|
|
||||||
getters: {
|
|
||||||
paths: vi.fn(() => ({
|
|
||||||
'log-base': '/tmp/logs',
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('execa', () => ({
|
|
||||||
execa: vi.fn().mockResolvedValue({ stdout: 'mocked output' }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('RestService Dependencies', () => {
|
|
||||||
it('should resolve ApiReportService dependency successfully', async () => {
|
|
||||||
const mockApiReportService = {
|
|
||||||
generateReport: vi.fn().mockResolvedValue({ timestamp: new Date().toISOString() }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const module = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
RestService,
|
|
||||||
{
|
|
||||||
provide: ApiReportService,
|
|
||||||
useValue: mockApiReportService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
const restService = module.get<RestService>(RestService);
|
|
||||||
expect(restService).toBeDefined();
|
|
||||||
expect(restService).toBeInstanceOf(RestService);
|
|
||||||
|
|
||||||
await module.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should fail gracefully when ApiReportService is missing', async () => {
|
|
||||||
// This test ensures we get a clear error when dependencies are missing
|
|
||||||
await expect(
|
|
||||||
Test.createTestingModule({
|
|
||||||
providers: [RestService],
|
|
||||||
}).compile()
|
|
||||||
).rejects.toThrow(/ApiReportService/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { CacheModule } from '@nestjs/cache-manager';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
|
|
||||||
import { CANONICAL_INTERNAL_CLIENT_TOKEN } from '@unraid/shared';
|
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
|
||||||
import { LogService } from '@app/unraid-api/cli/log.service.js';
|
|
||||||
import { RestModule } from '@app/unraid-api/rest/rest.module.js';
|
|
||||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
|
||||||
|
|
||||||
// Mock external dependencies that cause issues in tests
|
|
||||||
vi.mock('@app/store/index.js', () => ({
|
|
||||||
store: {
|
|
||||||
getState: vi.fn(() => ({
|
|
||||||
paths: {
|
|
||||||
'log-base': '/tmp/logs',
|
|
||||||
'auth-keys': '/tmp/auth-keys',
|
|
||||||
config: '/tmp/config',
|
|
||||||
},
|
|
||||||
emhttp: {},
|
|
||||||
dynamix: { notify: { path: '/tmp/notifications' } },
|
|
||||||
registration: {},
|
|
||||||
})),
|
|
||||||
subscribe: vi.fn(() => vi.fn()), // Return unsubscribe function
|
|
||||||
},
|
|
||||||
getters: {
|
|
||||||
paths: vi.fn(() => ({
|
|
||||||
'log-base': '/tmp/logs',
|
|
||||||
'auth-keys': '/tmp/auth-keys',
|
|
||||||
config: '/tmp/config',
|
|
||||||
})),
|
|
||||||
dynamix: vi.fn(() => ({ notify: { path: '/tmp/notifications' } })),
|
|
||||||
emhttp: vi.fn(() => ({})),
|
|
||||||
registration: vi.fn(() => ({})),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@app/core/log.js', () => ({
|
|
||||||
levels: ['trace', 'debug', 'info', 'warn', 'error', 'fatal'],
|
|
||||||
apiLogger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
},
|
|
||||||
pluginLogger: {
|
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
|
||||||
error: vi.fn(),
|
|
||||||
debug: vi.fn(),
|
|
||||||
trace: vi.fn(),
|
|
||||||
fatal: vi.fn(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('execa', () => ({
|
|
||||||
execa: vi.fn().mockResolvedValue({ stdout: 'mocked output' }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('RestModule Integration', () => {
|
|
||||||
it('should compile with RestService having access to ApiReportService', async () => {
|
|
||||||
const module = await Test.createTestingModule({
|
|
||||||
imports: [CacheModule.register({ isGlobal: true }), RestModule],
|
|
||||||
})
|
|
||||||
// Override services that have complex dependencies for testing
|
|
||||||
.overrideProvider(CANONICAL_INTERNAL_CLIENT_TOKEN)
|
|
||||||
.useValue({ getClient: vi.fn() })
|
|
||||||
.overrideProvider(LogService)
|
|
||||||
.useValue({ error: vi.fn(), debug: vi.fn() })
|
|
||||||
.compile();
|
|
||||||
|
|
||||||
const restService = module.get<RestService>(RestService);
|
|
||||||
const apiReportService = module.get<ApiReportService>(ApiReportService);
|
|
||||||
|
|
||||||
expect(restService).toBeDefined();
|
|
||||||
expect(apiReportService).toBeDefined();
|
|
||||||
|
|
||||||
// Verify RestService has the injected ApiReportService
|
|
||||||
expect(restService['apiReportService']).toBeDefined();
|
|
||||||
|
|
||||||
await module.close();
|
|
||||||
}, 10000);
|
|
||||||
});
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
import { Test } from '@nestjs/testing';
|
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
|
||||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
|
||||||
|
|
||||||
const mockWriteFile = vi.fn();
|
|
||||||
|
|
||||||
vi.mock('node:fs/promises', () => ({
|
|
||||||
writeFile: (...args: any[]) => mockWriteFile(...args),
|
|
||||||
stat: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock ApiReportService
|
|
||||||
const mockApiReportService = {
|
|
||||||
generateReport: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('RestService', () => {
|
|
||||||
let restService: RestService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
const module = await Test.createTestingModule({
|
|
||||||
providers: [RestService, { provide: ApiReportService, useValue: mockApiReportService }],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
restService = module.get<RestService>(RestService);
|
|
||||||
|
|
||||||
// Clear mocks
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('saveApiReport', () => {
|
|
||||||
it('should generate report using ApiReportService and save to file', async () => {
|
|
||||||
const mockReport = {
|
|
||||||
timestamp: '2023-01-01T00:00:00.000Z',
|
|
||||||
connectionStatus: {
|
|
||||||
running: 'yes' as const,
|
|
||||||
},
|
|
||||||
system: {
|
|
||||||
id: 'test-uuid',
|
|
||||||
name: 'Test Server',
|
|
||||||
version: '6.12.0',
|
|
||||||
machineId: 'REDACTED',
|
|
||||||
manufacturer: 'Test Manufacturer',
|
|
||||||
model: 'Test Model',
|
|
||||||
},
|
|
||||||
connect: {
|
|
||||||
installed: true,
|
|
||||||
dynamicRemoteAccess: {
|
|
||||||
enabledType: 'STATIC',
|
|
||||||
runningType: 'STATIC',
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
valid: true,
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
cloud: { name: 'cloud', online: true },
|
|
||||||
minigraph: { name: 'minigraph', online: false },
|
|
||||||
allServices: [],
|
|
||||||
},
|
|
||||||
remote: {
|
|
||||||
apikey: 'REDACTED',
|
|
||||||
localApiKey: 'REDACTED',
|
|
||||||
accesstoken: 'REDACTED',
|
|
||||||
idtoken: 'REDACTED',
|
|
||||||
refreshtoken: 'REDACTED',
|
|
||||||
ssoSubIds: 'REDACTED',
|
|
||||||
allowedOrigins: 'REDACTED',
|
|
||||||
email: 'REDACTED',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPath = '/tmp/test-report.json';
|
|
||||||
mockApiReportService.generateReport.mockResolvedValue(mockReport);
|
|
||||||
mockWriteFile.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await restService.saveApiReport(reportPath);
|
|
||||||
|
|
||||||
// Verify ApiReportService was called (defaults to API running)
|
|
||||||
expect(mockApiReportService.generateReport).toHaveBeenCalledWith();
|
|
||||||
|
|
||||||
// Verify file was written with correct content
|
|
||||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
||||||
reportPath,
|
|
||||||
JSON.stringify(mockReport, null, 2),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle ApiReportService errors gracefully', async () => {
|
|
||||||
const reportPath = '/tmp/test-report.json';
|
|
||||||
const error = new Error('Report generation failed');
|
|
||||||
mockApiReportService.generateReport.mockRejectedValue(error);
|
|
||||||
|
|
||||||
// Should not throw error
|
|
||||||
await restService.saveApiReport(reportPath);
|
|
||||||
|
|
||||||
// Verify ApiReportService was called
|
|
||||||
expect(mockApiReportService.generateReport).toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Verify file write was not called due to error
|
|
||||||
expect(mockWriteFile).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle file write errors gracefully', async () => {
|
|
||||||
const mockReport = {
|
|
||||||
timestamp: '2023-01-01T00:00:00.000Z',
|
|
||||||
system: { name: 'Test' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const reportPath = '/tmp/test-report.json';
|
|
||||||
mockApiReportService.generateReport.mockResolvedValue(mockReport);
|
|
||||||
mockWriteFile.mockRejectedValue(new Error('File write failed'));
|
|
||||||
|
|
||||||
// Should not throw error
|
|
||||||
await restService.saveApiReport(reportPath);
|
|
||||||
|
|
||||||
// Verify both service and file operations were attempted
|
|
||||||
expect(mockApiReportService.generateReport).toHaveBeenCalled();
|
|
||||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
||||||
reportPath,
|
|
||||||
JSON.stringify(mockReport, null, 2),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -34,7 +34,6 @@ describe('RestController', () => {
|
|||||||
{
|
{
|
||||||
provide: RestService,
|
provide: RestService,
|
||||||
useValue: {
|
useValue: {
|
||||||
getLogs: vi.fn(),
|
|
||||||
getCustomizationStream: vi.fn(),
|
getCustomizationStream: vi.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,21 +29,6 @@ export class RestController {
|
|||||||
return 'OK';
|
return 'OK';
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('/graphql/api/logs')
|
|
||||||
@UsePermissions({
|
|
||||||
action: AuthAction.READ_ANY,
|
|
||||||
resource: Resource.LOGS,
|
|
||||||
})
|
|
||||||
async getLogs(@Res() res: FastifyReply) {
|
|
||||||
try {
|
|
||||||
const logStream = await this.restService.getLogs();
|
|
||||||
return res.status(200).type('application/x-gtar').send(logStream);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
this.logger.error(error);
|
|
||||||
return res.status(500).send(`Error: Failed to get logs`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('/graphql/api/customizations/:type')
|
@Get('/graphql/api/customizations/:type')
|
||||||
@UsePermissions({
|
@UsePermissions({
|
||||||
action: AuthAction.READ_ANY,
|
action: AuthAction.READ_ANY,
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CliServicesModule } from '@app/unraid-api/cli/cli-services.module.js';
|
|
||||||
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
|
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
|
||||||
import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js';
|
import { SsoModule } from '@app/unraid-api/graph/resolvers/sso/sso.module.js';
|
||||||
import { RestController } from '@app/unraid-api/rest/rest.controller.js';
|
import { RestController } from '@app/unraid-api/rest/rest.controller.js';
|
||||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [RCloneModule, CliServicesModule, SsoModule],
|
imports: [RCloneModule, SsoModule],
|
||||||
controllers: [RestController],
|
controllers: [RestController],
|
||||||
providers: [RestService],
|
providers: [RestService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,33 +1,88 @@
|
|||||||
import type { TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { Test } from '@nestjs/testing';
|
import type { ReadStream } from 'node:fs';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
import {
|
||||||
|
getBannerPathIfPresent,
|
||||||
|
getCasePathIfPresent,
|
||||||
|
} from '@app/core/utils/images/image-file-helpers.js';
|
||||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
||||||
|
|
||||||
|
vi.mock('node:fs');
|
||||||
|
vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({
|
||||||
|
getBannerPathIfPresent: vi.fn(),
|
||||||
|
getCasePathIfPresent: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('RestService', () => {
|
describe('RestService', () => {
|
||||||
let service: RestService;
|
let service: RestService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const mockApiReportService = {
|
vi.clearAllMocks();
|
||||||
generateReport: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [RestService],
|
||||||
RestService,
|
|
||||||
{
|
|
||||||
provide: ApiReportService,
|
|
||||||
useValue: mockApiReportService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<RestService>(RestService);
|
service = module.get<RestService>(RestService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
describe('getCustomizationPath', () => {
|
||||||
expect(service).toBeDefined();
|
it('returns banner path when present', async () => {
|
||||||
|
const mockBannerPath = '/path/to/banner.png';
|
||||||
|
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath);
|
||||||
|
|
||||||
|
await expect(service.getCustomizationPath('banner')).resolves.toBe(mockBannerPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns case path when present', async () => {
|
||||||
|
const mockCasePath = '/path/to/case.png';
|
||||||
|
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath);
|
||||||
|
|
||||||
|
await expect(service.getCustomizationPath('case')).resolves.toBe(mockCasePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when no path is available', async () => {
|
||||||
|
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
|
||||||
|
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getCustomizationPath('banner')).resolves.toBeNull();
|
||||||
|
await expect(service.getCustomizationPath('case')).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCustomizationStream', () => {
|
||||||
|
it('returns read stream for banner', async () => {
|
||||||
|
const mockPath = '/path/to/banner.png';
|
||||||
|
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
||||||
|
|
||||||
|
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath);
|
||||||
|
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
||||||
|
|
||||||
|
await expect(service.getCustomizationStream('banner')).resolves.toBe(mockStream);
|
||||||
|
expect(createReadStream).toHaveBeenCalledWith(mockPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns read stream for case', async () => {
|
||||||
|
const mockPath = '/path/to/case.png';
|
||||||
|
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
||||||
|
|
||||||
|
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath);
|
||||||
|
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
||||||
|
|
||||||
|
await expect(service.getCustomizationStream('case')).resolves.toBe(mockStream);
|
||||||
|
expect(createReadStream).toHaveBeenCalledWith(mockPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when no customization is available', async () => {
|
||||||
|
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
|
||||||
|
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found');
|
||||||
|
await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,350 +0,0 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
|
||||||
import type { ReadStream, Stats } from 'node:fs';
|
|
||||||
import { createReadStream } from 'node:fs';
|
|
||||||
import { stat, writeFile } from 'node:fs/promises';
|
|
||||||
import { Readable } from 'node:stream';
|
|
||||||
|
|
||||||
import { execa, ExecaError } from 'execa';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import type { ApiReportData } from '@app/unraid-api/cli/api-report.service.js';
|
|
||||||
import {
|
|
||||||
getBannerPathIfPresent,
|
|
||||||
getCasePathIfPresent,
|
|
||||||
} from '@app/core/utils/images/image-file-helpers.js';
|
|
||||||
import { getters } from '@app/store/index.js';
|
|
||||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
|
||||||
import { RestService } from '@app/unraid-api/rest/rest.service.js';
|
|
||||||
|
|
||||||
vi.mock('node:fs');
|
|
||||||
vi.mock('node:fs/promises');
|
|
||||||
vi.mock('execa');
|
|
||||||
vi.mock('@app/store/index.js');
|
|
||||||
vi.mock('@app/core/utils/images/image-file-helpers.js', () => ({
|
|
||||||
getBannerPathIfPresent: vi.fn(),
|
|
||||||
getCasePathIfPresent: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('RestService', () => {
|
|
||||||
let service: RestService;
|
|
||||||
let apiReportService: ApiReportService;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
RestService,
|
|
||||||
{
|
|
||||||
provide: ApiReportService,
|
|
||||||
useValue: {
|
|
||||||
generateReport: vi.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = module.get<RestService>(RestService);
|
|
||||||
apiReportService = module.get<ApiReportService>(ApiReportService);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getLogs', () => {
|
|
||||||
const mockLogPath = '/usr/local/emhttp/logs/unraid-api';
|
|
||||||
const mockGraphqlApiLog = '/var/log/graphql-api.log';
|
|
||||||
const mockZipPath = '/usr/local/emhttp/logs/unraid-api.tar.gz';
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.mocked(getters).paths = vi.fn().mockReturnValue({
|
|
||||||
'log-base': mockLogPath,
|
|
||||||
});
|
|
||||||
// Mock saveApiReport to avoid side effects
|
|
||||||
vi.spyOn(service as any, 'saveApiReport').mockResolvedValue(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create and return log archive successfully', async () => {
|
|
||||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
|
||||||
vi.mocked(stat).mockImplementation((path) => {
|
|
||||||
if (path === mockLogPath || path === mockZipPath) {
|
|
||||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('File not found'));
|
|
||||||
});
|
|
||||||
vi.mocked(execa).mockResolvedValue({
|
|
||||||
stdout: '',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
} as any);
|
|
||||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
|
||||||
|
|
||||||
const result = await service.getLogs();
|
|
||||||
|
|
||||||
expect(execa).toHaveBeenCalledWith('tar', ['-czf', mockZipPath, mockLogPath], {
|
|
||||||
timeout: 60000,
|
|
||||||
reject: true,
|
|
||||||
});
|
|
||||||
expect(createReadStream).toHaveBeenCalledWith(mockZipPath);
|
|
||||||
expect(result).toBe(mockStream);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include graphql-api.log when it exists', async () => {
|
|
||||||
vi.mocked(stat).mockImplementation((path) => {
|
|
||||||
if (path === mockLogPath || path === mockGraphqlApiLog || path === mockZipPath) {
|
|
||||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('File not found'));
|
|
||||||
});
|
|
||||||
vi.mocked(execa).mockResolvedValue({
|
|
||||||
stdout: '',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
} as any);
|
|
||||||
vi.mocked(createReadStream).mockReturnValue(Readable.from([]) as ReadStream);
|
|
||||||
|
|
||||||
await service.getLogs();
|
|
||||||
|
|
||||||
expect(execa).toHaveBeenCalledWith(
|
|
||||||
'tar',
|
|
||||||
['-czf', mockZipPath, mockLogPath, mockGraphqlApiLog],
|
|
||||||
{
|
|
||||||
timeout: 60000,
|
|
||||||
reject: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle timeout errors with detailed message', async () => {
|
|
||||||
vi.mocked(stat).mockImplementation((path) => {
|
|
||||||
if (path === mockLogPath) {
|
|
||||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('File not found'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const timeoutError = new Error('Command timed out') as ExecaError;
|
|
||||||
timeoutError.timedOut = true;
|
|
||||||
timeoutError.command =
|
|
||||||
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
|
|
||||||
timeoutError.exitCode = undefined;
|
|
||||||
timeoutError.stderr = '';
|
|
||||||
timeoutError.stdout = '';
|
|
||||||
|
|
||||||
vi.mocked(execa).mockRejectedValue(timeoutError);
|
|
||||||
|
|
||||||
await expect(service.getLogs()).rejects.toThrow('Tar command timed out after 60 seconds');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle command failure with exit code and stderr', async () => {
|
|
||||||
vi.mocked(stat).mockImplementation((path) => {
|
|
||||||
if (path === mockLogPath) {
|
|
||||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('File not found'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const execError = new Error('Command failed') as ExecaError;
|
|
||||||
execError.exitCode = 1;
|
|
||||||
execError.command =
|
|
||||||
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
|
|
||||||
execError.stderr = 'tar: Cannot create archive';
|
|
||||||
execError.stdout = '';
|
|
||||||
execError.shortMessage = 'Command failed with exit code 1';
|
|
||||||
|
|
||||||
vi.mocked(execa).mockRejectedValue(execError);
|
|
||||||
|
|
||||||
await expect(service.getLogs()).rejects.toThrow('Tar command failed with exit code 1');
|
|
||||||
await expect(service.getLogs()).rejects.toThrow('tar: Cannot create archive');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle case when tar succeeds but zip file is not created', async () => {
|
|
||||||
vi.mocked(stat).mockImplementation((path) => {
|
|
||||||
if (path === mockLogPath) {
|
|
||||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
|
||||||
}
|
|
||||||
// Zip file doesn't exist after tar command
|
|
||||||
return Promise.reject(new Error('File not found'));
|
|
||||||
});
|
|
||||||
vi.mocked(execa).mockResolvedValue({
|
|
||||||
stdout: '',
|
|
||||||
stderr: '',
|
|
||||||
exitCode: 0,
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
await expect(service.getLogs()).rejects.toThrow(
|
|
||||||
'Failed to create log zip - tar file not found after successful command'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error when log path does not exist', async () => {
|
|
||||||
vi.mocked(stat).mockRejectedValue(new Error('File not found'));
|
|
||||||
|
|
||||||
await expect(service.getLogs()).rejects.toThrow('No logs to download');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle generic errors', async () => {
|
|
||||||
vi.mocked(stat).mockImplementation((path) => {
|
|
||||||
if (path === mockLogPath) {
|
|
||||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('File not found'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const genericError = new Error('Unexpected error');
|
|
||||||
vi.mocked(execa).mockRejectedValue(genericError);
|
|
||||||
|
|
||||||
await expect(service.getLogs()).rejects.toThrow(
|
|
||||||
'Failed to create logs archive: Unexpected error'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle errors with stdout in addition to stderr', async () => {
|
|
||||||
vi.mocked(stat).mockImplementation((path) => {
|
|
||||||
if (path === mockLogPath) {
|
|
||||||
return Promise.resolve({ isFile: () => true } as unknown as Stats);
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('File not found'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const execError = new Error('Command failed') as ExecaError;
|
|
||||||
execError.exitCode = 1;
|
|
||||||
execError.command =
|
|
||||||
'tar -czf /usr/local/emhttp/logs/unraid-api.tar.gz /usr/local/emhttp/logs/unraid-api';
|
|
||||||
execError.stderr = 'tar: Error';
|
|
||||||
execError.stdout = 'Processing archive...';
|
|
||||||
execError.shortMessage = 'Command failed with exit code 1';
|
|
||||||
|
|
||||||
vi.mocked(execa).mockRejectedValue(execError);
|
|
||||||
|
|
||||||
await expect(service.getLogs()).rejects.toThrow('Stdout: Processing archive');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('saveApiReport', () => {
|
|
||||||
it('should generate and save API report', async () => {
|
|
||||||
const mockReport: ApiReportData = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
connectionStatus: { running: 'yes' },
|
|
||||||
system: {
|
|
||||||
name: 'Test Server',
|
|
||||||
version: '6.12.0',
|
|
||||||
machineId: 'test-machine-id',
|
|
||||||
},
|
|
||||||
connect: {
|
|
||||||
installed: false,
|
|
||||||
},
|
|
||||||
config: {
|
|
||||||
valid: true,
|
|
||||||
},
|
|
||||||
services: {
|
|
||||||
cloud: null,
|
|
||||||
minigraph: null,
|
|
||||||
allServices: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const mockPath = '/test/report.json';
|
|
||||||
|
|
||||||
vi.mocked(apiReportService.generateReport).mockResolvedValue(mockReport);
|
|
||||||
vi.mocked(writeFile).mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await service.saveApiReport(mockPath);
|
|
||||||
|
|
||||||
expect(apiReportService.generateReport).toHaveBeenCalled();
|
|
||||||
expect(writeFile).toHaveBeenCalledWith(
|
|
||||||
mockPath,
|
|
||||||
JSON.stringify(mockReport, null, 2),
|
|
||||||
'utf-8'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle errors when generating report', async () => {
|
|
||||||
const mockPath = '/test/report.json';
|
|
||||||
|
|
||||||
vi.mocked(apiReportService.generateReport).mockRejectedValue(
|
|
||||||
new Error('Report generation failed')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should not throw, just log warning
|
|
||||||
await expect(service.saveApiReport(mockPath)).resolves.toBeUndefined();
|
|
||||||
expect(apiReportService.generateReport).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getCustomizationPath', () => {
|
|
||||||
it('should return banner path when type is banner', async () => {
|
|
||||||
const mockBannerPath = '/path/to/banner.png';
|
|
||||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockBannerPath);
|
|
||||||
|
|
||||||
const result = await service.getCustomizationPath('banner');
|
|
||||||
|
|
||||||
expect(getBannerPathIfPresent).toHaveBeenCalled();
|
|
||||||
expect(result).toBe(mockBannerPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return case path when type is case', async () => {
|
|
||||||
const mockCasePath = '/path/to/case.png';
|
|
||||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockCasePath);
|
|
||||||
|
|
||||||
const result = await service.getCustomizationPath('case');
|
|
||||||
|
|
||||||
expect(getCasePathIfPresent).toHaveBeenCalled();
|
|
||||||
expect(result).toBe(mockCasePath);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when no banner found', async () => {
|
|
||||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await service.getCustomizationPath('banner');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return null when no case found', async () => {
|
|
||||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await service.getCustomizationPath('case');
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getCustomizationStream', () => {
|
|
||||||
it('should return read stream for banner', async () => {
|
|
||||||
const mockPath = '/path/to/banner.png';
|
|
||||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
|
||||||
|
|
||||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(mockPath);
|
|
||||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
|
||||||
|
|
||||||
const result = await service.getCustomizationStream('banner');
|
|
||||||
|
|
||||||
expect(getBannerPathIfPresent).toHaveBeenCalled();
|
|
||||||
expect(createReadStream).toHaveBeenCalledWith(mockPath);
|
|
||||||
expect(result).toBe(mockStream);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return read stream for case', async () => {
|
|
||||||
const mockPath = '/path/to/case.png';
|
|
||||||
const mockStream: ReadStream = Readable.from([]) as ReadStream;
|
|
||||||
|
|
||||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(mockPath);
|
|
||||||
vi.mocked(createReadStream).mockReturnValue(mockStream);
|
|
||||||
|
|
||||||
const result = await service.getCustomizationStream('case');
|
|
||||||
|
|
||||||
expect(getCasePathIfPresent).toHaveBeenCalled();
|
|
||||||
expect(createReadStream).toHaveBeenCalledWith(mockPath);
|
|
||||||
expect(result).toBe(mockStream);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error when no banner found', async () => {
|
|
||||||
vi.mocked(getBannerPathIfPresent).mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.getCustomizationStream('banner')).rejects.toThrow('No banner found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should throw error when no case found', async () => {
|
|
||||||
vi.mocked(getCasePathIfPresent).mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.getCustomizationStream('case')).rejects.toThrow('No case found');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,111 +1,14 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { ReadStream } from 'node:fs';
|
import type { ReadStream } from 'node:fs';
|
||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
import { stat, writeFile } from 'node:fs/promises';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
import type { ExecaError } from 'execa';
|
|
||||||
import { execa } from 'execa';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getBannerPathIfPresent,
|
getBannerPathIfPresent,
|
||||||
getCasePathIfPresent,
|
getCasePathIfPresent,
|
||||||
} from '@app/core/utils/images/image-file-helpers.js';
|
} from '@app/core/utils/images/image-file-helpers.js';
|
||||||
import { getters } from '@app/store/index.js';
|
|
||||||
import { ApiReportService } from '@app/unraid-api/cli/api-report.service.js';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RestService {
|
export class RestService {
|
||||||
protected logger = new Logger(RestService.name);
|
|
||||||
|
|
||||||
constructor(private readonly apiReportService: ApiReportService) {}
|
|
||||||
|
|
||||||
async saveApiReport(pathToReport: string) {
|
|
||||||
try {
|
|
||||||
const apiReport = await this.apiReportService.generateReport();
|
|
||||||
this.logger.debug('Report object %o', apiReport);
|
|
||||||
await writeFile(pathToReport, JSON.stringify(apiReport, null, 2), 'utf-8');
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn('Could not generate report for zip with error %o', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLogs(): Promise<ReadStream> {
|
|
||||||
const logPath = getters.paths()['log-base'];
|
|
||||||
const graphqlApiLog = '/var/log/graphql-api.log';
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.saveApiReport(join(logPath, 'report.json'));
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn('Could not generate report for zip with error %o', error);
|
|
||||||
}
|
|
||||||
const zipToWrite = join(logPath, '../unraid-api.tar.gz');
|
|
||||||
|
|
||||||
const logPathExists = Boolean(await stat(logPath).catch(() => null));
|
|
||||||
if (logPathExists) {
|
|
||||||
try {
|
|
||||||
// Build tar command arguments
|
|
||||||
const tarArgs = ['-czf', zipToWrite, logPath];
|
|
||||||
|
|
||||||
// Check if graphql-api.log exists and add it to the archive
|
|
||||||
const graphqlLogExists = Boolean(await stat(graphqlApiLog).catch(() => null));
|
|
||||||
if (graphqlLogExists) {
|
|
||||||
tarArgs.push(graphqlApiLog);
|
|
||||||
this.logger.debug('Including graphql-api.log in archive');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute tar with timeout and capture output
|
|
||||||
await execa('tar', tarArgs, {
|
|
||||||
timeout: 60000, // 60 seconds timeout for tar operation
|
|
||||||
reject: true, // Throw on non-zero exit (default behavior)
|
|
||||||
});
|
|
||||||
|
|
||||||
const tarFileExists = Boolean(await stat(zipToWrite).catch(() => null));
|
|
||||||
|
|
||||||
if (tarFileExists) {
|
|
||||||
return createReadStream(zipToWrite);
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
'Failed to create log zip - tar file not found after successful command'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Build detailed error message with execa's built-in error info
|
|
||||||
let errorMessage = 'Failed to create logs archive';
|
|
||||||
|
|
||||||
if (error && typeof error === 'object' && 'command' in error) {
|
|
||||||
const execaError = error as ExecaError;
|
|
||||||
|
|
||||||
if (execaError.timedOut) {
|
|
||||||
errorMessage = `Tar command timed out after 60 seconds. Command: ${execaError.command}`;
|
|
||||||
} else if (execaError.exitCode !== undefined) {
|
|
||||||
errorMessage = `Tar command failed with exit code ${execaError.exitCode}. Command: ${execaError.command}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add stderr/stdout if available
|
|
||||||
if (execaError.stderr) {
|
|
||||||
errorMessage += `. Stderr: ${execaError.stderr}`;
|
|
||||||
}
|
|
||||||
if (execaError.stdout) {
|
|
||||||
errorMessage += `. Stdout: ${execaError.stdout}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include the short message from execa
|
|
||||||
if (execaError.shortMessage) {
|
|
||||||
errorMessage += `. Details: ${execaError.shortMessage}`;
|
|
||||||
}
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage += `: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.error(errorMessage, error);
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('No logs to download');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCustomizationPath(type: 'banner' | 'case'): Promise<string | null> {
|
async getCustomizationPath(type: 'banner' | 'case'): Promise<string | null> {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'banner':
|
case 'banner':
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
/**
|
|
||||||
* DownloadApiLogs Component Test Coverage
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mount } from '@vue/test-utils';
|
|
||||||
|
|
||||||
import { BrandButton } from '@unraid/ui';
|
|
||||||
import { createTestingPinia } from '@pinia/testing';
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue';
|
|
||||||
import { createTestI18n, testTranslate } from '../utils/i18n';
|
|
||||||
|
|
||||||
vi.mock('~/helpers/urls', () => ({
|
|
||||||
CONNECT_FORUMS: new URL('http://mock-forums.local'),
|
|
||||||
CONTACT: new URL('http://mock-contact.local'),
|
|
||||||
DISCORD: new URL('http://mock-discord.local'),
|
|
||||||
WEBGUI_GRAPHQL: '/graphql',
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('vue-i18n', async (importOriginal) => {
|
|
||||||
const actual = (await importOriginal()) as typeof import('vue-i18n');
|
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
useI18n: () => ({
|
|
||||||
t: testTranslate,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('DownloadApiLogs', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
// Mock global csrf_token
|
|
||||||
globalThis.csrf_token = 'mock-csrf-token';
|
|
||||||
});
|
|
||||||
|
|
||||||
it('provides a download button with the correct URL', () => {
|
|
||||||
const wrapper = mount(DownloadApiLogs, {
|
|
||||||
global: {
|
|
||||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
|
||||||
stubs: {
|
|
||||||
ArrowDownTrayIcon: true,
|
|
||||||
ArrowTopRightOnSquareIcon: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Expected download URL
|
|
||||||
const expectedUrl = '/graphql/api/logs?csrf_token=mock-csrf-token';
|
|
||||||
|
|
||||||
// Find the download button
|
|
||||||
const downloadButton = wrapper.findComponent(BrandButton);
|
|
||||||
|
|
||||||
// Verify download button exists and has correct attributes
|
|
||||||
expect(downloadButton.exists()).toBe(true);
|
|
||||||
expect(downloadButton.attributes('href')).toBe(expectedUrl);
|
|
||||||
expect(downloadButton.attributes('download')).toBe('');
|
|
||||||
expect(downloadButton.attributes('target')).toBe('_blank');
|
|
||||||
expect(downloadButton.attributes('rel')).toBe('noopener noreferrer');
|
|
||||||
expect(downloadButton.text()).toContain(testTranslate('downloadApiLogs.downloadUnraidApiLogs'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays support links to documentation and help resources', () => {
|
|
||||||
const wrapper = mount(DownloadApiLogs, {
|
|
||||||
global: {
|
|
||||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
|
||||||
stubs: {
|
|
||||||
ArrowDownTrayIcon: true,
|
|
||||||
ArrowTopRightOnSquareIcon: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const links = wrapper.findAll('a');
|
|
||||||
expect(links.length).toBe(4);
|
|
||||||
|
|
||||||
expect(links[1].attributes('href')).toBe('http://mock-forums.local/');
|
|
||||||
expect(links[1].text()).toContain(testTranslate('downloadApiLogs.unraidConnectForums'));
|
|
||||||
|
|
||||||
expect(links[2].attributes('href')).toBe('http://mock-discord.local/');
|
|
||||||
expect(links[2].text()).toContain(testTranslate('downloadApiLogs.unraidDiscord'));
|
|
||||||
|
|
||||||
expect(links[3].attributes('href')).toBe('http://mock-contact.local/');
|
|
||||||
expect(links[3].text()).toContain(testTranslate('downloadApiLogs.unraidContactPage'));
|
|
||||||
|
|
||||||
links.slice(1).forEach((link) => {
|
|
||||||
expect(link.attributes('target')).toBe('_blank');
|
|
||||||
expect(link.attributes('rel')).toBe('noopener noreferrer');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('displays instructions about log usage and privacy', () => {
|
|
||||||
const wrapper = mount(DownloadApiLogs, {
|
|
||||||
global: {
|
|
||||||
plugins: [createTestingPinia({ createSpy: vi.fn }), createTestI18n()],
|
|
||||||
stubs: {
|
|
||||||
ArrowDownTrayIcon: true,
|
|
||||||
ArrowTopRightOnSquareIcon: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const text = wrapper.text();
|
|
||||||
|
|
||||||
expect(text).toContain(testTranslate('downloadApiLogs.thePrimaryMethodOfSupportFor'));
|
|
||||||
expect(text).toContain(testTranslate('downloadApiLogs.ifYouAreAskedToSupply'));
|
|
||||||
expect(text).toContain(testTranslate('downloadApiLogs.theLogsMayContainSensitiveInformation'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -14,7 +14,6 @@ vi.mock('@/components/HeaderOsVersion.standalone.vue', () => ({ default: 'Header
|
|||||||
vi.mock('@/components/UserProfile.standalone.vue', () => ({ default: 'UserProfile' }));
|
vi.mock('@/components/UserProfile.standalone.vue', () => ({ default: 'UserProfile' }));
|
||||||
vi.mock('../Auth.standalone.vue', () => ({ default: 'Auth' }));
|
vi.mock('../Auth.standalone.vue', () => ({ default: 'Auth' }));
|
||||||
vi.mock('../ConnectSettings/ConnectSettings.standalone.vue', () => ({ default: 'ConnectSettings' }));
|
vi.mock('../ConnectSettings/ConnectSettings.standalone.vue', () => ({ default: 'ConnectSettings' }));
|
||||||
vi.mock('../DownloadApiLogs.standalone.vue', () => ({ default: 'DownloadApiLogs' }));
|
|
||||||
vi.mock('@/components/Modals.standalone.vue', () => ({ default: 'Modals' }));
|
vi.mock('@/components/Modals.standalone.vue', () => ({ default: 'Modals' }));
|
||||||
vi.mock('../Registration.standalone.vue', () => ({ default: 'Registration' }));
|
vi.mock('../Registration.standalone.vue', () => ({ default: 'Registration' }));
|
||||||
vi.mock('../WanIpCheck.standalone.vue', () => ({ default: 'WanIpCheck' }));
|
vi.mock('../WanIpCheck.standalone.vue', () => ({ default: 'WanIpCheck' }));
|
||||||
@@ -135,7 +134,6 @@ describe('component-registry', () => {
|
|||||||
'user-profile',
|
'user-profile',
|
||||||
'auth',
|
'auth',
|
||||||
'connect-settings',
|
'connect-settings',
|
||||||
'download-api-logs',
|
|
||||||
'modals',
|
'modals',
|
||||||
'registration',
|
'registration',
|
||||||
'wan-ip-check',
|
'wan-ip-check',
|
||||||
|
|||||||
@@ -16,9 +16,6 @@ vi.mock('~/components/Auth.standalone.vue', () => ({
|
|||||||
vi.mock('~/components/ConnectSettings/ConnectSettings.standalone.vue', () => ({
|
vi.mock('~/components/ConnectSettings/ConnectSettings.standalone.vue', () => ({
|
||||||
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' },
|
default: { name: 'MockConnectSettings', template: '<div>ConnectSettings</div>' },
|
||||||
}));
|
}));
|
||||||
vi.mock('~/components/DownloadApiLogs.standalone.vue', () => ({
|
|
||||||
default: { name: 'MockDownloadApiLogs', template: '<div>DownloadApiLogs</div>' },
|
|
||||||
}));
|
|
||||||
vi.mock('~/components/HeaderOsVersion.standalone.vue', () => ({
|
vi.mock('~/components/HeaderOsVersion.standalone.vue', () => ({
|
||||||
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' },
|
default: { name: 'MockHeaderOsVersion', template: '<div>HeaderOsVersion</div>' },
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -138,7 +138,6 @@ if ($display['theme'] === 'black' || $display['theme'] === 'azure') {
|
|||||||
<unraid-auth></connect-auth>
|
<unraid-auth></connect-auth>
|
||||||
</div>
|
</div>
|
||||||
<div class="ComponentWrapper">
|
<div class="ComponentWrapper">
|
||||||
<unraid-download-api-logs></connect-download-api-logs>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="ComponentWrapper">
|
<div class="ComponentWrapper">
|
||||||
<unraid-key-actions></connect-key-actions>
|
<unraid-key-actions></connect-key-actions>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
updateConnectSettings,
|
updateConnectSettings,
|
||||||
} from '~/components/ConnectSettings/graphql/settings.query';
|
} from '~/components/ConnectSettings/graphql/settings.query';
|
||||||
import OidcDebugLogs from '~/components/ConnectSettings/OidcDebugLogs.vue';
|
import OidcDebugLogs from '~/components/ConnectSettings/OidcDebugLogs.vue';
|
||||||
import DownloadApiLogs from '~/components/DownloadApiLogs.standalone.vue';
|
|
||||||
import { useServerStore } from '~/store/server';
|
import { useServerStore } from '~/store/server';
|
||||||
|
|
||||||
// Disable automatic attribute inheritance
|
// Disable automatic attribute inheritance
|
||||||
@@ -115,8 +114,6 @@ const onChange = ({ data }: { data: Record<string, unknown> }) => {
|
|||||||
<Label>{{ t('connectSettings.accountStatusLabel') }}</Label>
|
<Label>{{ t('connectSettings.accountStatusLabel') }}</Label>
|
||||||
<Auth />
|
<Auth />
|
||||||
</template>
|
</template>
|
||||||
<Label>{{ t('downloadApiLogs.downloadUnraidApiLogs') }}:</Label>
|
|
||||||
<DownloadApiLogs />
|
|
||||||
</SettingsGrid>
|
</SettingsGrid>
|
||||||
<!-- auto-generated settings form -->
|
<!-- auto-generated settings form -->
|
||||||
<div class="mt-6 pl-3 [&_.vertical-layout]:space-y-6">
|
<div class="mt-6 pl-3 [&_.vertical-layout]:space-y-6">
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
|
||||||
|
|
||||||
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid';
|
|
||||||
import { BrandButton } from '@unraid/ui';
|
|
||||||
import { CONNECT_FORUMS, CONTACT, DISCORD, WEBGUI_GRAPHQL } from '~/helpers/urls';
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const joinPaths = (base: string, path: string) => {
|
|
||||||
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
|
||||||
const normalizedPath = path.startsWith('/') ? path.slice(1) : path;
|
|
||||||
return `${normalizedBase}/${normalizedPath}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadUrl = computed(() => {
|
|
||||||
const csrfToken = globalThis.csrf_token ?? '';
|
|
||||||
const downloadPath = joinPaths(WEBGUI_GRAPHQL, '/api/logs');
|
|
||||||
const params = new URLSearchParams({ csrf_token: csrfToken });
|
|
||||||
return `${downloadPath}?${params.toString()}`;
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex max-w-3xl flex-col gap-y-4 whitespace-normal">
|
|
||||||
<p class="text-start text-sm">
|
|
||||||
{{ t('downloadApiLogs.thePrimaryMethodOfSupportFor') }}
|
|
||||||
{{ t('downloadApiLogs.ifYouAreAskedToSupply') }}
|
|
||||||
{{ t('downloadApiLogs.theLogsMayContainSensitiveInformation') }}
|
|
||||||
</p>
|
|
||||||
<span class="flex flex-col gap-y-4">
|
|
||||||
<div class="flex">
|
|
||||||
<BrandButton
|
|
||||||
class="shrink-0 grow-0"
|
|
||||||
download
|
|
||||||
:external="true"
|
|
||||||
:href="downloadUrl"
|
|
||||||
:icon="ArrowDownTrayIcon"
|
|
||||||
size="12px"
|
|
||||||
:text="t('downloadApiLogs.downloadUnraidApiLogs')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-row items-baseline gap-2">
|
|
||||||
<a
|
|
||||||
:href="CONNECT_FORUMS.toString()"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
|
|
||||||
>
|
|
||||||
{{ t('downloadApiLogs.unraidConnectForums') }}
|
|
||||||
<ArrowTopRightOnSquareIcon class="w-4" />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
:href="DISCORD.toString()"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
|
|
||||||
>
|
|
||||||
{{ t('downloadApiLogs.unraidDiscord') }}
|
|
||||||
<ArrowTopRightOnSquareIcon class="w-4" />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
:href="CONTACT.toString()"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="inline-flex flex-row items-center justify-start gap-2 text-[#486dba] hover:text-[#3b5ea9] hover:underline focus:text-[#3b5ea9] focus:underline"
|
|
||||||
>
|
|
||||||
{{ t('downloadApiLogs.unraidContactPage') }}
|
|
||||||
<ArrowTopRightOnSquareIcon class="w-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -41,11 +41,6 @@ export const componentMappings: ComponentMapping[] = [
|
|||||||
selector: 'unraid-connect-settings',
|
selector: 'unraid-connect-settings',
|
||||||
appId: 'connect-settings',
|
appId: 'connect-settings',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
component: defineAsyncComponent(() => import('../DownloadApiLogs.standalone.vue')),
|
|
||||||
selector: 'unraid-download-api-logs',
|
|
||||||
appId: 'download-api-logs',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
component: defineAsyncComponent(() => import('@/components/Modals.standalone.vue')),
|
component: defineAsyncComponent(() => import('@/components/Modals.standalone.vue')),
|
||||||
selector: ['unraid-modals', '#modals', 'modals-direct'], // All possible modal selectors
|
selector: ['unraid-modals', '#modals', 'modals-direct'], // All possible modal selectors
|
||||||
|
|||||||
@@ -122,11 +122,6 @@ watch(
|
|||||||
<ConnectSettingsCe />
|
<ConnectSettingsCe />
|
||||||
<hr class="border-muted" />
|
<hr class="border-muted" />
|
||||||
|
|
||||||
<!-- <h3 class="text-lg font-semibold font-mono">
|
|
||||||
DownloadApiLogsCe
|
|
||||||
</h3>
|
|
||||||
<DownloadApiLogsCe />
|
|
||||||
<hr class="border-black dark:border-white"> -->
|
|
||||||
<!-- <h3 class="text-lg font-semibold font-mono">
|
<!-- <h3 class="text-lg font-semibold font-mono">
|
||||||
AuthCe
|
AuthCe
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -29,9 +29,6 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>API Logs</h2>
|
<h2>API Logs</h2>
|
||||||
<p class="card-description">Download and view API access logs</p>
|
<p class="card-description">Download and view API access logs</p>
|
||||||
<div class="component-mount" data-component="unraid-download-api-logs">
|
|
||||||
<unraid-download-api-logs></unraid-download-api-logs>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
1
web/types/window.d.ts
vendored
1
web/types/window.d.ts
vendored
@@ -25,7 +25,6 @@ declare global {
|
|||||||
// These are generated for each component in componentMappings
|
// These are generated for each component in componentMappings
|
||||||
mountAuth?: (selector?: string) => unknown;
|
mountAuth?: (selector?: string) => unknown;
|
||||||
mountConnectSettings?: (selector?: string) => unknown;
|
mountConnectSettings?: (selector?: string) => unknown;
|
||||||
mountDownloadApiLogs?: (selector?: string) => unknown;
|
|
||||||
mountHeaderOsVersion?: (selector?: string) => unknown;
|
mountHeaderOsVersion?: (selector?: string) => unknown;
|
||||||
mountModals?: (selector?: string) => unknown;
|
mountModals?: (selector?: string) => unknown;
|
||||||
mountModalsLegacy?: (selector?: string) => unknown;
|
mountModalsLegacy?: (selector?: string) => unknown;
|
||||||
|
|||||||
Reference in New Issue
Block a user