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
3 changes: 3 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"moduleNameMapper": {
"^(\\.{1,2}/.*)\\.js$": "$1"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "agents" ADD COLUMN "displayName" TEXT;
2 changes: 2 additions & 0 deletions apps/server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ model Agent {
id String @id @default(cuid())
uuid String @unique
hostname String
/// Operator-set dashboard label. Null until an admin names the host.
displayName String?
os String
architecture String
version String
Expand Down
67 changes: 67 additions & 0 deletions apps/server/src/agents/agents.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { AgentRegisterPayload } from '@docksight/protocol';
import { AgentsService } from './agents.service';
import { PrismaService } from '../common/database/prisma.service';

describe('AgentsService', () => {
const payload: AgentRegisterPayload = {
uuid: 'agent-uuid',
hostname: 'ip-10-0-0-1',
os: 'linux',
architecture: 'x64',
version: '1.0.0',
};

it('does not overwrite displayName on re-registration', async () => {
const upsert = jest.fn().mockResolvedValue({
id: 'host-1',
uuid: payload.uuid,
hostname: payload.hostname,
status: 'ONLINE',
});
const service = new AgentsService({
agent: { upsert },
} as unknown as PrismaService);

await service.register(payload);

const call = upsert.mock.calls[0] as unknown as [
{ update: Record<string, unknown> },
];
expect(call[0].update).not.toHaveProperty('displayName');
});

it('updates displayName for an existing host', async () => {
const existing = { id: 'host-1', hostname: 'ip-10-0-0-1' };
const updated = { ...existing, displayName: 'prod-web-1' };
const prisma = {
agent: {
findUnique: jest.fn().mockResolvedValue(existing),
update: jest.fn().mockResolvedValue(updated),
},
};
const service = new AgentsService(prisma as unknown as PrismaService);

await expect(
service.updateDisplayName('host-1', 'prod-web-1'),
).resolves.toEqual(updated);
expect(prisma.agent.update).toHaveBeenCalledWith({
where: { id: 'host-1' },
data: { displayName: 'prod-web-1' },
});
});

it('returns null when updating a missing host', async () => {
const prisma = {
agent: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn(),
},
};
const service = new AgentsService(prisma as unknown as PrismaService);

await expect(
service.updateDisplayName('missing', 'prod-web-1'),
).resolves.toBeNull();
expect(prisma.agent.update).not.toHaveBeenCalled();
});
});
16 changes: 16 additions & 0 deletions apps/server/src/agents/agents.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export class AgentsService {
version: payload.version,
status: AgentStatus.ONLINE,
lastSeen: now,
// displayName is operator-set and must survive re-registration
},
});

Expand Down Expand Up @@ -77,6 +78,21 @@ export class AgentsService {
}
}

async updateDisplayName(
id: string,
displayName: string,
): Promise<Agent | null> {
const existing = await this.findById(id);
if (!existing) {
return null;
}

return this.prisma.agent.update({
where: { id },
data: { displayName },
});
}
Comment on lines +81 to +94

findByUuid(uuid: string): Promise<Agent | null> {
return this.prisma.agent.findUnique({ where: { uuid } });
}
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/hosts/dto/update-host.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';

export const HOST_DISPLAY_NAME_MAX_LENGTH = 64;

export class UpdateHostDto {
@ApiProperty({
example: 'prod-web-1',
minLength: 1,
maxLength: HOST_DISPLAY_NAME_MAX_LENGTH,
})
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@IsNotEmpty({ message: 'Display name must not be empty' })
@MaxLength(HOST_DISPLAY_NAME_MAX_LENGTH, {
message: `Display name must be at most ${HOST_DISPLAY_NAME_MAX_LENGTH} characters`,
})
displayName!: string;
}
118 changes: 118 additions & 0 deletions apps/server/src/hosts/hosts.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
CanActivate,
ExecutionContext,
INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { APP_GUARD, Reflector } from '@nestjs/core';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import type { App } from 'supertest/types';
import type { UserRole } from '../../generated/prisma/client';
import { RolesGuard } from '../auth/roles.guard';
import { HOST_DISPLAY_NAME_MAX_LENGTH } from './dto/update-host.dto';
import { HostsController } from './hosts.controller';
import { HostsService } from './hosts.service';

describe('HostsController', () => {
let app: INestApplication;
let role: UserRole;
const updateDisplayName = jest.fn();

beforeEach(async () => {
role = 'ADMIN';
updateDisplayName.mockReset();

const authGuard: CanActivate = {
canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest<{
user?: { id: string; role: UserRole };
}>();
req.user = { id: 'user-1', role };
return true;
},
};

const moduleRef = await Test.createTestingModule({
controllers: [HostsController],
providers: [
Reflector,
{
provide: HostsService,
useValue: { updateDisplayName },
},
{ provide: APP_GUARD, useValue: authGuard },
{ provide: APP_GUARD, useClass: RolesGuard },
],
}).compile();

app = moduleRef.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
await app.init();
});

afterEach(async () => {
await app.close();
});

it('lets an admin update the display name', async () => {
updateDisplayName.mockResolvedValue({
id: 'host-1',
hostname: 'ip-10-0-0-1',
displayName: 'prod-web-1',
});

const res = await request(app.getHttpServer() as App)
.patch('/hosts/host-1')
.send({ displayName: ' prod-web-1 ' })
.expect(200);

expect(updateDisplayName).toHaveBeenCalledWith('host-1', 'prod-web-1');
expect(res.body).toEqual(
expect.objectContaining({ displayName: 'prod-web-1' }),
);
});

it('forbids VIEWER from updating the display name', async () => {
role = 'VIEWER';

await request(app.getHttpServer() as App)
.patch('/hosts/host-1')
.send({ displayName: 'prod-web-1' })
.expect(403);

expect(updateDisplayName).not.toHaveBeenCalled();
});

it('rejects an empty display name', async () => {
await request(app.getHttpServer() as App)
.patch('/hosts/host-1')
.send({ displayName: ' ' })
.expect(400);

expect(updateDisplayName).not.toHaveBeenCalled();
});

it('rejects a display name that is too long', async () => {
await request(app.getHttpServer() as App)
.patch('/hosts/host-1')
.send({ displayName: 'a'.repeat(HOST_DISPLAY_NAME_MAX_LENGTH + 1) })
.expect(400);

expect(updateDisplayName).not.toHaveBeenCalled();
});

it('returns 404 when the host does not exist', async () => {
updateDisplayName.mockResolvedValue(null);

await request(app.getHttpServer() as App)
.patch('/hosts/missing')
.send({ displayName: 'prod-web-1' })
.expect(404);
});
});
43 changes: 41 additions & 2 deletions apps/server/src/hosts/hosts.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
import { Controller, Get, NotFoundException, Param } from '@nestjs/common';
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
Body,
Controller,
Get,
NotFoundException,
Param,
Patch,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiForbiddenResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { Roles } from '../auth/roles.decorator';
import { UpdateHostDto } from './dto/update-host.dto';
import { HostsService } from './hosts.service';

@ApiTags('hosts')
Expand All @@ -14,6 +31,28 @@ export class HostsController {
return this.hostsService.listHosts();
}

@Patch(':id')
@Roles('ADMIN')
@ApiBearerAuth()
@ApiOperation({ summary: 'Update a host display name' })
@ApiBody({ type: UpdateHostDto })
@ApiOkResponse({ description: 'Updated host' })
@ApiUnauthorizedResponse({ description: 'Missing or invalid token' })
@ApiForbiddenResponse({ description: 'Requires the ADMIN role' })
async updateDisplayName(
@Param('id') id: string,
@Body() body: UpdateHostDto,
) {
const result = await this.hostsService.updateDisplayName(
id,
body.displayName,
);
if (!result) {
throw new NotFoundException(`Host not found: ${id}`);
}
return result;
}

@Get(':id/metrics')
@ApiOperation({ summary: 'Latest CPU and memory usage reported by a host' })
@ApiOkResponse({ description: 'Host resource usage snapshot' })
Expand Down
Loading
Loading