Browse Source

feat(groups): add client group management API

pull/2681/head
SPetrakis 2 weeks ago
parent
commit
f119fae29b
Failed to extract signature
  1. 29
      src/server/api/client-group/[groupId]/index.delete.ts
  2. 28
      src/server/api/client-group/[groupId]/index.get.ts
  3. 48
      src/server/api/client-group/[groupId]/index.post.ts
  4. 6
      src/server/api/client-group/index.get.ts
  5. 34
      src/server/api/client-group/index.post.ts
  6. 32
      src/server/api/client/[clientId]/group.delete.ts
  7. 48
      src/server/api/client/[clientId]/group.post.ts
  8. 128
      src/server/database/repositories/clientGroup/service.ts
  9. 47
      src/server/database/repositories/clientGroup/types.ts
  10. 163
      src/test/unit/clientGroup.spec.ts
  11. 187
      src/test/unit/clientGroupRoutes.spec.ts
  12. 1
      src/vitest.config.ts

29
src/server/api/client-group/[groupId]/index.delete.ts

@ -0,0 +1,29 @@
import { createError, getValidatedRouterParams } from 'h3';
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
import { validateZod } from '#server/utils/types';
import { ClientGroupGetSchema } from '#db/repositories/clientGroup/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const { groupId } = await getValidatedRouterParams(
event,
validateZod(ClientGroupGetSchema, event)
);
const group = await Database.clientGroups.get(groupId);
if (!group) {
throw createError({
statusCode: 404,
statusMessage: 'Client group not found',
});
}
await Database.clientGroups.delete(groupId);
return { success: true };
}
);

28
src/server/api/client-group/[groupId]/index.get.ts

@ -0,0 +1,28 @@
import { createError, getValidatedRouterParams } from 'h3';
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
import { validateZod } from '#server/utils/types';
import { ClientGroupGetSchema } from '#db/repositories/clientGroup/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const { groupId } = await getValidatedRouterParams(
event,
validateZod(ClientGroupGetSchema, event)
);
const group = await Database.clientGroups.getDetails(groupId);
if (!group) {
throw createError({
statusCode: 404,
statusMessage: 'Client group not found',
});
}
return group;
}
);

48
src/server/api/client-group/[groupId]/index.post.ts

@ -0,0 +1,48 @@
import { createError, getValidatedRouterParams, readValidatedBody } from 'h3';
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
import { validateZod } from '#server/utils/types';
import {
ClientGroupGetSchema,
ClientGroupUpdateSchema,
} from '#db/repositories/clientGroup/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const { groupId } = await getValidatedRouterParams(
event,
validateZod(ClientGroupGetSchema, event)
);
const data = await readValidatedBody(
event,
validateZod(ClientGroupUpdateSchema, event)
);
try {
const group = await Database.clientGroups.update(groupId, data);
return { success: true, group };
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Client group not found') {
throw createError({
statusCode: 404,
statusMessage: error.message,
});
}
if (error.message === 'Client group already exists') {
throw createError({
statusCode: 409,
statusMessage: error.message,
});
}
}
throw error;
}
}
);

6
src/server/api/client-group/index.get.ts

@ -0,0 +1,6 @@
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
export default definePermissionEventHandler('admin', 'any', async () => {
return Database.clientGroups.list();
});

34
src/server/api/client-group/index.post.ts

@ -0,0 +1,34 @@
import { createError, readValidatedBody } from 'h3';
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
import { validateZod } from '#server/utils/types';
import { ClientGroupCreateSchema } from '#db/repositories/clientGroup/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const data = await readValidatedBody(
event,
validateZod(ClientGroupCreateSchema, event)
);
try {
const group = await Database.clientGroups.create(data);
return { success: true, group };
} catch (error) {
if (
error instanceof Error &&
error.message === 'Client group already exists'
) {
throw createError({
statusCode: 409,
statusMessage: error.message,
});
}
throw error;
}
}
);

32
src/server/api/client/[clientId]/group.delete.ts

@ -0,0 +1,32 @@
import { createError, getValidatedRouterParams } from 'h3';
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
import { validateZod } from '#server/utils/types';
import { ClientGroupClientParamsSchema } from '#db/repositories/clientGroup/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGroupClientParamsSchema, event)
);
try {
await Database.clientGroups.getClientGroupId(clientId);
await Database.clientGroups.unassignClient(clientId);
return { success: true };
} catch (error) {
if (error instanceof Error && error.message === 'Client not found') {
throw createError({
statusCode: 404,
statusMessage: error.message,
});
}
throw error;
}
}
);

48
src/server/api/client/[clientId]/group.post.ts

@ -0,0 +1,48 @@
import { createError, getValidatedRouterParams, readValidatedBody } from 'h3';
import Database from '#server/utils/Database';
import { definePermissionEventHandler } from '#server/utils/handler';
import { validateZod } from '#server/utils/types';
import {
ClientGroupAssignSchema,
ClientGroupClientParamsSchema,
} from '#db/repositories/clientGroup/types';
export default definePermissionEventHandler(
'admin',
'any',
async ({ event }) => {
const { clientId } = await getValidatedRouterParams(
event,
validateZod(ClientGroupClientParamsSchema, event)
);
const { groupId } = await readValidatedBody(
event,
validateZod(ClientGroupAssignSchema, event)
);
try {
await Database.clientGroups.assignClient(clientId, groupId);
return { success: true };
} catch (error) {
if (error instanceof Error) {
if (error.message === 'Client not found') {
throw createError({
statusCode: 404,
statusMessage: error.message,
});
}
if (error.message === 'Client group not found') {
throw createError({
statusCode: 404,
statusMessage: error.message,
});
}
}
throw error;
}
}
);

128
src/server/database/repositories/clientGroup/service.ts

@ -1,8 +1,10 @@
import { count, eq, sql } from 'drizzle-orm';
import { and, count, eq, ne, sql } from 'drizzle-orm';
import { clientGroup } from './schema';
import type {
ClientGroupCreateType,
ClientGroupDetailsType,
ClientGroupMemberType,
ClientGroupResultType,
ClientGroupUpdateType,
ClientGroupWithCountType,
@ -13,6 +15,43 @@ import { client } from '#db/schema';
import type { DBType } from '#db/sqlite';
import type { ID } from '#server/utils/types';
const CLIENT_GROUP_NAME_CONFLICT_MESSAGE = 'Client group already exists';
export function isClientGroupNameConflictError(error: unknown) {
let currentError = error;
while (currentError instanceof Error) {
const code = (currentError as { code?: unknown }).code;
const message = currentError.message;
const isSqliteConstraint =
code === 'SQLITE_CONSTRAINT' ||
code === 'SQLITE_CONSTRAINT_UNIQUE' ||
message.includes('SQLITE_CONSTRAINT') ||
message.includes('UNIQUE constraint failed');
if (
isSqliteConstraint &&
(message.includes('client_groups_table.name') ||
message.includes('client_groups_table_name_unique'))
) {
return true;
}
currentError = (currentError as { cause?: unknown }).cause;
}
return false;
}
function throwClientGroupNameConflict(error: unknown): never {
if (isClientGroupNameConflictError(error)) {
throw new Error(CLIENT_GROUP_NAME_CONFLICT_MESSAGE);
}
throw error;
}
function createPreparedStatement(db: DBType) {
return {
findById: db.query.clientGroup
@ -74,6 +113,22 @@ export class ClientGroupService {
};
}
#toClientGroupMember(row: {
id: number;
name: string;
enabled: boolean;
ipv4Address: string;
ipv6Address: string;
createdAt: string;
updatedAt: string;
}): ClientGroupMemberType {
return {
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
};
}
#withAssignedClientCount() {
return {
id: clientGroup.id,
@ -90,11 +145,20 @@ export class ClientGroupService {
async create(data: ClientGroupCreateType) {
const parsedData = ClientGroupCreateSchema.parse(data);
const existingGroup = await this.#db.query.clientGroup
.findFirst({ where: eq(clientGroup.name, parsedData.name) })
.execute();
if (existingGroup) {
throw new Error('Client group already exists');
}
const [createdGroup] = await this.#db
.insert(clientGroup)
.values(parsedData)
.returning()
.execute();
.execute()
.catch(throwClientGroupNameConflict);
if (!createdGroup) {
throw new Error('Client group was not created');
@ -127,14 +191,57 @@ export class ClientGroupService {
return group ? this.#toClientGroupWithCount(group) : undefined;
}
async getDetails(id: ID): Promise<ClientGroupDetailsType | undefined> {
const group = await this.get(id);
if (!group) {
return undefined;
}
const clients = await this.#db.query.client
.findMany({
where: eq(client.groupId, id),
columns: {
id: true,
name: true,
enabled: true,
ipv4Address: true,
ipv6Address: true,
createdAt: true,
updatedAt: true,
},
orderBy: (t, { asc }) => asc(t.name),
})
.execute();
return {
...group,
clients: clients.map((member) => this.#toClientGroupMember(member)),
};
}
async update(id: ID, data: ClientGroupUpdateType) {
const parsedData = ClientGroupUpdateSchema.parse(data);
const existingGroup = await this.#db.query.clientGroup
.findFirst({
where: and(
eq(clientGroup.name, parsedData.name),
ne(clientGroup.id, id)
),
})
.execute();
if (existingGroup) {
throw new Error('Client group already exists');
}
const [updatedGroup] = await this.#db
.update(clientGroup)
.set(parsedData)
.where(eq(clientGroup.id, id))
.returning()
.execute();
.execute()
.catch(throwClientGroupNameConflict);
if (!updatedGroup) {
throw new Error('Client group not found');
@ -180,4 +287,19 @@ export class ClientGroupService {
async countAssignedClients(groupId: ID) {
return this.#db.$count(client, eq(client.groupId, groupId));
}
async getClientGroupId(clientId: ID) {
const txClient = await this.#db.query.client
.findFirst({
where: eq(client.id, clientId),
columns: { groupId: true },
})
.execute();
if (!txClient) {
throw new Error('Client not found');
}
return txClient.groupId;
}
}

47
src/server/database/repositories/clientGroup/types.ts

@ -1,5 +1,7 @@
import type { InferSelectModel } from 'drizzle-orm';
import z from 'zod';
import { isIP } from 'is-ip';
import isCidr from 'is-cidr';
import type { clientGroup } from './schema';
@ -34,6 +36,20 @@ export type ClientGroupWithCountType = ClientGroupResultType & {
assignedClientCount: number;
};
export type ClientGroupMemberType = {
id: number;
name: string;
enabled: boolean;
ipv4Address: string;
ipv6Address: string;
createdAt: Date;
updatedAt: Date;
};
export type ClientGroupDetailsType = ClientGroupWithCountType & {
clients: ClientGroupMemberType[];
};
const name = z
.string({ message: t('zod.clientGroup.name') })
.trim()
@ -47,14 +63,43 @@ const description = z
.pipe(controlStringRefine)
.nullable();
const groupAllowedIp = AddressSchema.refine(
(value) => isIP(value) || isCidr(value),
{
message: t('zod.allowedIps'),
}
);
export const ClientGroupCreateSchema = schemaForType<ClientGroupCreateType>()(
z.object({
name,
description,
allowedIps: z.array(AddressSchema).nullable(),
allowedIps: z.array(groupAllowedIp).nullable(),
dns: DnsSchema.nullable(),
firewallIps: FirewallIpsSchema.nullable(),
})
);
export const ClientGroupUpdateSchema = ClientGroupCreateSchema;
const groupId = z.coerce
.number({ message: t('zod.clientGroup.id') })
.int()
.positive();
const clientId = z.coerce
.number({ message: t('zod.client.id') })
.int()
.positive();
export const ClientGroupGetSchema = z.object({
groupId,
});
export const ClientGroupAssignSchema = z.object({
groupId,
});
export const ClientGroupClientParamsSchema = z.object({
clientId,
});

163
src/test/unit/clientGroup.spec.ts

@ -6,8 +6,18 @@ import { createClient } from '@libsql/client';
import { drizzle } from 'drizzle-orm/libsql';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { ClientGroupService } from '../../server/database/repositories/clientGroup/service';
import {
ClientGroupService,
isClientGroupNameConflictError,
} from '../../server/database/repositories/clientGroup/service';
import {
ClientGroupAssignSchema,
ClientGroupClientParamsSchema,
ClientGroupCreateSchema,
ClientGroupGetSchema,
} from '../../server/database/repositories/clientGroup/types';
import * as schema from '../../server/database/schema';
import { hasPermissions, roles } from '../../shared/utils/permissions';
const migrationsThrough0006 = [
'0000_short_skin.sql',
@ -271,6 +281,35 @@ describe('ClientGroupService', () => {
await expect(service.unassignClient(9999)).resolves.toBeDefined();
});
test('moves clients between groups and assigning to the same group is idempotent', async () => {
const existingClient = await seedClient(db);
const firstGroup = await service.create({
name: 'Customers',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
});
const secondGroup = await service.create({
name: 'Staff',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
});
await service.assignClient(existingClient.id, firstGroup.id);
await service.assignClient(existingClient.id, firstGroup.id);
await expect(service.countAssignedClients(firstGroup.id)).resolves.toBe(1);
await service.assignClient(existingClient.id, secondGroup.id);
await expect(service.countAssignedClients(firstGroup.id)).resolves.toBe(0);
await expect(service.countAssignedClients(secondGroup.id)).resolves.toBe(1);
await expect(db.query.client.findFirst()).resolves.toMatchObject({
groupId: secondGroup.id,
});
});
test('deleting a group with assigned clients sets clients to unassigned', async () => {
const existingClient = await seedClient(db);
const group = await service.create({
@ -344,6 +383,45 @@ describe('ClientGroupService', () => {
});
});
test('rejects invalid group values without collapsing null or empty arrays', async () => {
await expect(
service.create({
name: 'Invalid Allowed IPs',
description: null,
allowedIps: ['not-an-ip-or-cidr'],
dns: null,
firewallIps: null,
})
).rejects.toThrow();
await expect(
service.create({
name: 'Invalid DNS',
description: null,
allowedIps: null,
dns: [''],
firewallIps: null,
})
).rejects.toThrow();
await expect(
service.create({
name: 'Invalid Firewall',
description: null,
allowedIps: null,
dns: null,
firewallIps: ['10.0.0.1/tcp'],
})
).rejects.toThrow();
expect(() => ClientGroupCreateSchema.parse({ name: ' ' })).toThrow();
expect(() => ClientGroupGetSchema.parse({ groupId: 'abc' })).toThrow();
expect(() =>
ClientGroupClientParamsSchema.parse({ clientId: 'abc' })
).toThrow();
expect(() => ClientGroupAssignSchema.parse({ groupId: 'abc' })).toThrow();
});
test('trims names, rejects whitespace-only names, and rejects exact duplicates', async () => {
await expect(
service.create({
@ -376,6 +454,89 @@ describe('ClientGroupService', () => {
).rejects.toThrow();
});
test('detects only client group name unique-constraint errors', async () => {
await service.create({
name: 'Customers',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
});
let groupConflictError: unknown;
try {
await db
.insert(schema.clientGroup)
.values({
name: 'Customers',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
})
.execute();
} catch (error) {
groupConflictError = error;
}
expect(isClientGroupNameConflictError(groupConflictError)).toBe(true);
await seedClient(db);
let unrelatedConflictError: unknown;
try {
await seedClient(db, {
name: 'Second Phone',
publicKey: 'public-2',
});
} catch (error) {
unrelatedConflictError = error;
}
expect(isClientGroupNameConflictError(unrelatedConflictError)).toBe(false);
});
test('group details include safe assigned-client summaries only', async () => {
const existingClient = await seedClient(db, {
privateKey: 'super-private',
preSharedKey: 'super-psk',
});
const group = await service.create({
name: 'Customers',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
});
await service.assignClient(existingClient.id, group.id);
const details = await service.getDetails(group.id);
expect(details).toMatchObject({
id: group.id,
assignedClientCount: 1,
clients: [
{
id: existingClient.id,
name: 'Phone',
enabled: true,
ipv4Address: '10.8.0.2',
ipv6Address: 'fdcc:ad94:bacf:61a4::cafe:2',
},
],
});
expect(details?.clients[0]).not.toHaveProperty('privateKey');
expect(details?.clients[0]).not.toHaveProperty('preSharedKey');
});
test('administrator permission model allows group APIs and client role does not', () => {
expect(hasPermissions({ id: 1, role: roles.ADMIN }, 'admin', 'any')).toBe(
true
);
expect(hasPermissions({ id: 2, role: roles.CLIENT }, 'admin', 'any')).toBe(
false
);
});
test('upgrades existing 0006 clients through migration 0007', async () => {
await client.close();
const testDb = await createTestDb(migrationsThrough0006);

187
src/test/unit/clientGroupRoutes.spec.ts

@ -0,0 +1,187 @@
import { createEvent } from 'h3';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { roles } from '../../shared/utils/permissions';
const mockDatabase = {
clientGroups: {
create: vi.fn(),
getDetails: vi.fn(),
list: vi.fn(),
},
};
const mockGetCurrentUser = vi.fn();
vi.mock('#server/utils/Database', () => ({
default: mockDatabase,
}));
vi.mock('#server/utils/session', () => ({
getCurrentUser: mockGetCurrentUser,
}));
function createH3Event(
url: string,
options: RequestInit & { params?: Record<string, string> } = {}
) {
const event = createEvent(new Request(url, options));
event.context.params = options.params;
return event;
}
describe('client group route handlers', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetCurrentUser.mockResolvedValue({
id: 1,
username: 'admin',
password: 'hash',
email: null,
name: 'Administrator',
role: roles.ADMIN,
totpKey: null,
totpVerified: false,
enabled: true,
oauthProvider: null,
oauthId: null,
createdAt: '',
updatedAt: '',
});
});
test('rejects non-admin access before reaching the list handler', async () => {
mockGetCurrentUser.mockResolvedValueOnce({
id: 2,
username: 'client',
password: 'hash',
email: null,
name: 'Client',
role: roles.CLIENT,
totpKey: null,
totpVerified: false,
enabled: true,
oauthProvider: null,
oauthId: null,
createdAt: '',
updatedAt: '',
});
const handler = (await import('../../server/api/client-group/index.get'))
.default;
await expect(
handler(createH3Event('http://wg.test/api/client-group'))
).rejects.toMatchObject({ statusCode: 403 });
expect(mockDatabase.clientGroups.list).not.toHaveBeenCalled();
});
test('allows admin access to reach the list handler', async () => {
mockDatabase.clientGroups.list.mockResolvedValueOnce([]);
const handler = (await import('../../server/api/client-group/index.get'))
.default;
await expect(
handler(createH3Event('http://wg.test/api/client-group'))
).resolves.toEqual([]);
expect(mockDatabase.clientGroups.list).toHaveBeenCalledOnce();
});
test('malformed group id rejects before lookup', async () => {
const handler = (
await import('../../server/api/client-group/[groupId]/index.get')
).default;
await expect(
handler(
createH3Event('http://wg.test/api/client-group/not-a-number', {
params: { groupId: 'not-a-number' },
})
)
).rejects.toThrow();
expect(mockDatabase.clientGroups.getDetails).not.toHaveBeenCalled();
});
test('missing group returns 404', async () => {
mockDatabase.clientGroups.getDetails.mockResolvedValueOnce(undefined);
const handler = (
await import('../../server/api/client-group/[groupId]/index.get')
).default;
await expect(
handler(
createH3Event('http://wg.test/api/client-group/123', {
params: { groupId: '123' },
})
)
).rejects.toMatchObject({
statusCode: 404,
statusMessage: 'Client group not found',
});
});
test('duplicate group create returns 409', async () => {
mockDatabase.clientGroups.create.mockRejectedValueOnce(
new Error('Client group already exists')
);
const handler = (await import('../../server/api/client-group/index.post'))
.default;
await expect(
handler(
createH3Event('http://wg.test/api/client-group', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
name: 'Customers',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
}),
})
)
).rejects.toMatchObject({
statusCode: 409,
statusMessage: 'Client group already exists',
});
});
test('successful detail response does not expose client secrets', async () => {
mockDatabase.clientGroups.getDetails.mockResolvedValueOnce({
id: 1,
name: 'Customers',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
assignedClientCount: 1,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
clients: [
{
id: 7,
name: 'Phone',
enabled: true,
ipv4Address: '10.8.0.2',
ipv6Address: 'fdcc:ad94:bacf:61a4::cafe:2',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
},
],
});
const handler = (
await import('../../server/api/client-group/[groupId]/index.get')
).default;
const response = await handler(
createH3Event('http://wg.test/api/client-group/1', {
params: { groupId: '1' },
})
);
expect(response.clients[0]).not.toHaveProperty('privateKey');
expect(response.clients[0]).not.toHaveProperty('preSharedKey');
});
});

1
src/vitest.config.ts

@ -10,6 +10,7 @@ export default defineConfig({
alias: {
'#db': fileURLToPath(new URL('./server/database', import.meta.url)),
'#server': fileURLToPath(new URL('./server', import.meta.url)),
'#shared': fileURLToPath(new URL('./shared', import.meta.url)),
},
},
test: {

Loading…
Cancel
Save