Browse Source

feat(groups): add client group persistence foundation

pull/2681/head
SPetrakis 2 weeks ago
parent
commit
53cff8beba
Failed to extract signature
  1. 7
      src/server/api/client/[clientId]/index.get.ts
  2. 14
      src/server/database/migrations/0007_amused_madame_web.sql
  3. 1127
      src/server/database/migrations/meta/0007_snapshot.json
  4. 7
      src/server/database/migrations/meta/_journal.json
  5. 18
      src/server/database/repositories/client/schema.ts
  6. 3
      src/server/database/repositories/client/service.ts
  7. 19
      src/server/database/repositories/client/types.ts
  8. 30
      src/server/database/repositories/clientGroup/schema.ts
  9. 183
      src/server/database/repositories/clientGroup/service.ts
  10. 60
      src/server/database/repositories/clientGroup/types.ts
  11. 1
      src/server/database/schema.ts
  12. 3
      src/server/database/sqlite.ts
  13. 444
      src/test/unit/clientGroup.spec.ts
  14. 1
      src/vitest.config.ts

7
src/server/api/client/[clientId]/index.get.ts

@ -4,7 +4,10 @@ import Database from '#server/utils/Database';
import WireGuard from '#server/utils/WireGuard';
import { definePermissionEventHandler } from '#server/utils/handler';
import { validateZod } from '#server/utils/types';
import { ClientGetSchema } from '#db/repositories/client/types';
import {
ClientGetSchema,
toCurrentPublicClient,
} from '#db/repositories/client/types';
export default definePermissionEventHandler(
'clients',
@ -29,7 +32,7 @@ export default definePermissionEventHandler(
const data = await WireGuard.dumpByPublicKey(result.publicKey);
return {
...result,
...toCurrentPublicClient(result),
endpoint: data?.endpoint,
};
}

14
src/server/database/migrations/0007_amused_madame_web.sql

@ -0,0 +1,14 @@
CREATE TABLE `client_groups_table` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`description` text,
`allowed_ips` text,
`dns` text,
`firewall_ips` text,
`created_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL,
`updated_at` text DEFAULT (CURRENT_TIMESTAMP) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `client_groups_table_name_unique` ON `client_groups_table` (`name`);--> statement-breakpoint
ALTER TABLE `clients_table` ADD `group_id` integer REFERENCES `client_groups_table`(`id`) ON UPDATE cascade ON DELETE set null;--> statement-breakpoint
CREATE INDEX `clients_table_group_id_index` ON `clients_table` (`group_id`);

1127
src/server/database/migrations/meta/0007_snapshot.json

File diff suppressed because it is too large

7
src/server/database/migrations/meta/_journal.json

@ -50,6 +50,13 @@
"when": 1782122902196,
"tag": "0006_clear_leech",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1782574033706,
"tag": "0007_amused_madame_web",
"breakpoints": true
}
]
}

18
src/server/database/repositories/client/schema.ts

@ -1,9 +1,16 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import {
index,
int,
sqliteTable,
text,
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
import { wgInterface } from '../interface/schema';
import { oneTimeLink } from '../oneTimeLink/schema';
import { user } from '../user/schema';
import { clientGroup } from '../clientGroup/schema';
/** null means use value from userConfig */
@ -34,6 +41,10 @@ export const client = sqliteTable(
publicKey: text('public_key').notNull(),
preSharedKey: text('pre_shared_key').notNull(),
expiresAt: text('expires_at'),
groupId: int('group_id').references(() => clientGroup.id, {
onDelete: 'set null',
onUpdate: 'cascade',
}),
allowedIps: text('allowed_ips', { mode: 'json' }).$type<string[]>(),
serverAllowedIps: text('server_allowed_ips', { mode: 'json' })
.$type<string[]>()
@ -64,6 +75,7 @@ export const client = sqliteTable(
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
},
(table) => [
index('clients_table_group_id_index').on(table.groupId),
uniqueIndex('public_key_interface_unique').on(
table.publicKey,
table.interfaceId
@ -80,6 +92,10 @@ export const clientsRelations = relations(client, ({ one }) => ({
fields: [client.userId],
references: [user.id],
}),
group: one(clientGroup, {
fields: [client.groupId],
references: [clientGroup.id],
}),
interface: one(wgInterface, {
fields: [client.interfaceId],
references: [wgInterface.name],

3
src/server/database/repositories/client/service.ts

@ -2,6 +2,7 @@ import { eq, sql, or, like, and } from 'drizzle-orm';
import { containsCidr, parseCidr } from 'cidr-tools';
import { client } from './schema';
import { CurrentPublicClientColumns } from './types';
import type {
ClientCreateFromExistingType,
ClientCreateType,
@ -85,6 +86,7 @@ export class ClientService {
},
where: and(...filters),
columns: {
...CurrentPublicClientColumns,
privateKey: false,
preSharedKey: false,
},
@ -128,6 +130,7 @@ export class ClientService {
where: and(eq(client.userId, userId), ...filters),
with: { oneTimeLink: true },
columns: {
...CurrentPublicClientColumns,
privateKey: false,
preSharedKey: false,
},

19
src/server/database/repositories/client/types.ts

@ -25,6 +25,18 @@ import {
export type ClientType = InferSelectModel<typeof client>;
export const CurrentPublicClientColumns = {
groupId: false,
} as const;
export function toCurrentPublicClient<T extends { groupId: number | null }>(
client: T
) {
const { groupId: _groupId, ...publicClient } = client;
return publicClient;
}
export type ClientNextIpType = Pick<ClientType, 'ipv4Address' | 'ipv6Address'>;
export type CreateClientType = Omit<
@ -34,7 +46,12 @@ export type CreateClientType = Omit<
export type UpdateClientType = Omit<
CreateClientType,
'privateKey' | 'publicKey' | 'preSharedKey' | 'userId' | 'interfaceId'
| 'privateKey'
| 'publicKey'
| 'preSharedKey'
| 'userId'
| 'interfaceId'
| 'groupId'
>;
const name = z

30
src/server/database/repositories/clientGroup/schema.ts

@ -0,0 +1,30 @@
import { sql, relations } from 'drizzle-orm';
import { int, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { client } from '../client/schema';
export const clientGroup = sqliteTable(
'client_groups_table',
{
id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(),
description: text(),
allowedIps: text('allowed_ips', { mode: 'json' }).$type<string[]>(),
dns: text({ mode: 'json' }).$type<string[]>(),
firewallIps: text('firewall_ips', { mode: 'json' }).$type<
string[] | null
>(),
createdAt: text('created_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`),
updatedAt: text('updated_at')
.notNull()
.default(sql`(CURRENT_TIMESTAMP)`)
.$onUpdate(() => sql`(CURRENT_TIMESTAMP)`),
},
(table) => [uniqueIndex('client_groups_table_name_unique').on(table.name)]
);
export const clientGroupRelations = relations(clientGroup, ({ many }) => ({
clients: many(client),
}));

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

@ -0,0 +1,183 @@
import { count, eq, sql } from 'drizzle-orm';
import { clientGroup } from './schema';
import type {
ClientGroupCreateType,
ClientGroupResultType,
ClientGroupUpdateType,
ClientGroupWithCountType,
} from './types';
import { ClientGroupCreateSchema, ClientGroupUpdateSchema } from './types';
import { client } from '#db/schema';
import type { DBType } from '#db/sqlite';
import type { ID } from '#server/utils/types';
function createPreparedStatement(db: DBType) {
return {
findById: db.query.clientGroup
.findFirst({ where: eq(clientGroup.id, sql.placeholder('id')) })
.prepare(),
delete: db
.delete(clientGroup)
.where(eq(clientGroup.id, sql.placeholder('id')))
.prepare(),
unassignClient: db
.update(client)
.set({ groupId: null })
.where(eq(client.id, sql.placeholder('clientId')))
.prepare(),
};
}
export class ClientGroupService {
#db: DBType;
#statements: ReturnType<typeof createPreparedStatement>;
constructor(db: DBType) {
this.#db = db;
this.#statements = createPreparedStatement(db);
}
#toClientGroup(row: {
id: number;
name: string;
description: string | null;
allowedIps: string[] | null;
dns: string[] | null;
firewallIps: string[] | null;
createdAt: string;
updatedAt: string;
}): ClientGroupResultType {
return {
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
};
}
#toClientGroupWithCount(row: {
id: number;
name: string;
description: string | null;
allowedIps: string[] | null;
dns: string[] | null;
firewallIps: string[] | null;
createdAt: string;
updatedAt: string;
assignedClientCount: number;
}): ClientGroupWithCountType {
return {
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
};
}
#withAssignedClientCount() {
return {
id: clientGroup.id,
name: clientGroup.name,
description: clientGroup.description,
allowedIps: clientGroup.allowedIps,
dns: clientGroup.dns,
firewallIps: clientGroup.firewallIps,
createdAt: clientGroup.createdAt,
updatedAt: clientGroup.updatedAt,
assignedClientCount: count(client.id),
};
}
async create(data: ClientGroupCreateType) {
const parsedData = ClientGroupCreateSchema.parse(data);
const [createdGroup] = await this.#db
.insert(clientGroup)
.values(parsedData)
.returning()
.execute();
if (!createdGroup) {
throw new Error('Client group was not created');
}
return this.#toClientGroup(createdGroup);
}
async list(): Promise<ClientGroupWithCountType[]> {
const groups = await this.#db
.select(this.#withAssignedClientCount())
.from(clientGroup)
.leftJoin(client, eq(client.groupId, clientGroup.id))
.groupBy(clientGroup.id)
.orderBy(clientGroup.name)
.execute();
return groups.map((group) => this.#toClientGroupWithCount(group));
}
async get(id: ID): Promise<ClientGroupWithCountType | undefined> {
const [group] = await this.#db
.select(this.#withAssignedClientCount())
.from(clientGroup)
.leftJoin(client, eq(client.groupId, clientGroup.id))
.where(eq(clientGroup.id, id))
.groupBy(clientGroup.id)
.execute();
return group ? this.#toClientGroupWithCount(group) : undefined;
}
async update(id: ID, data: ClientGroupUpdateType) {
const parsedData = ClientGroupUpdateSchema.parse(data);
const [updatedGroup] = await this.#db
.update(clientGroup)
.set(parsedData)
.where(eq(clientGroup.id, id))
.returning()
.execute();
if (!updatedGroup) {
throw new Error('Client group not found');
}
return this.#toClientGroup(updatedGroup);
}
delete(id: ID) {
return this.#statements.delete.execute({ id });
}
async assignClient(clientId: ID, groupId: ID) {
return this.#db.transaction(async (tx) => {
const txClient = await tx.query.client
.findFirst({ where: eq(client.id, clientId) })
.execute();
if (!txClient) {
throw new Error('Client not found');
}
const txGroup = await tx.query.clientGroup
.findFirst({ where: eq(clientGroup.id, groupId) })
.execute();
if (!txGroup) {
throw new Error('Client group not found');
}
await tx
.update(client)
.set({ groupId })
.where(eq(client.id, clientId))
.execute();
});
}
unassignClient(clientId: ID) {
return this.#statements.unassignClient.execute({ clientId });
}
async countAssignedClients(groupId: ID) {
return this.#db.$count(client, eq(client.groupId, groupId));
}
}

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

@ -0,0 +1,60 @@
import type { InferSelectModel } from 'drizzle-orm';
import z from 'zod';
import type { clientGroup } from './schema';
import {
AddressSchema,
DnsSchema,
FirewallIpsSchema,
controlStringRefine,
safeStringRefine,
schemaForType,
t,
} from '#server/utils/types';
export type ClientGroupType = InferSelectModel<typeof clientGroup>;
export type ClientGroupCreateType = Pick<
ClientGroupType,
'name' | 'description' | 'allowedIps' | 'dns' | 'firewallIps'
>;
export type ClientGroupUpdateType = ClientGroupCreateType;
export type ClientGroupResultType = Omit<
ClientGroupType,
'createdAt' | 'updatedAt'
> & {
createdAt: Date;
updatedAt: Date;
};
export type ClientGroupWithCountType = ClientGroupResultType & {
assignedClientCount: number;
};
const name = z
.string({ message: t('zod.clientGroup.name') })
.trim()
.min(1, t('zod.clientGroup.name'))
.pipe(safeStringRefine)
.pipe(controlStringRefine);
const description = z
.string({ message: t('zod.clientGroup.description') })
.pipe(safeStringRefine)
.pipe(controlStringRefine)
.nullable();
export const ClientGroupCreateSchema = schemaForType<ClientGroupCreateType>()(
z.object({
name,
description,
allowedIps: z.array(AddressSchema).nullable(),
dns: DnsSchema.nullable(),
firewallIps: FirewallIpsSchema.nullable(),
})
);
export const ClientGroupUpdateSchema = ClientGroupCreateSchema;

1
src/server/database/schema.ts

@ -1,5 +1,6 @@
// ! Do not use Path Aliases in this or any of these files
export * from './repositories/client/schema';
export * from './repositories/clientGroup/schema';
export * from './repositories/general/schema';
export * from './repositories/hooks/schema';
export * from './repositories/interface/schema';

3
src/server/database/sqlite.ts

@ -11,6 +11,7 @@ import { InterfaceService } from '#db/repositories/interface/service';
import { HooksService } from '#db/repositories/hooks/service';
import { OneTimeLinkService } from '#db/repositories/oneTimeLink/service';
import { ClientService } from '#db/repositories/client/service';
import { ClientGroupService } from '#db/repositories/clientGroup/service';
import * as schema from '#db/schema';
import { WG_ENV, WG_INITIAL_ENV } from '#server/utils/config';
@ -37,6 +38,7 @@ export async function connect() {
class DBService {
clients: ClientService;
clientGroups: ClientGroupService;
general: GeneralService;
users: UserService;
userConfigs: UserConfigService;
@ -46,6 +48,7 @@ class DBService {
constructor(db: DBType) {
this.clients = new ClientService(db);
this.clientGroups = new ClientGroupService(db);
this.general = new GeneralService(db);
this.users = new UserService(db);
this.userConfigs = new UserConfigService(db);

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

@ -0,0 +1,444 @@
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
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 * as schema from '../../server/database/schema';
const migrationsThrough0006 = [
'0000_short_skin.sql',
'0001_classy_the_stranger.sql',
'0002_keen_sleepwalker.sql',
'0003_breezy_colossus.sql',
'0004_optimal_mandrill.sql',
'0005_clumsy_korg.sql',
'0006_clear_leech.sql',
];
const migration0007 = '0007_amused_madame_web.sql';
type LibsqlClient = ReturnType<typeof createClient>;
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
async function applyMigration(client: LibsqlClient, migration: string) {
const sql = readFileSync(
join(process.cwd(), 'server', 'database', 'migrations', migration),
'utf8'
);
const statements = sql
.split('--> statement-breakpoint')
.map((statement) => statement.trim())
.filter((statement) => statement !== 'PRAGMA journal_mode=WAL;')
.filter(Boolean);
for (const statement of statements) {
await client.execute(statement);
}
}
async function applyMigrations(client: LibsqlClient, migrations: string[]) {
for (const migration of migrations) {
await applyMigration(client, migration);
}
}
async function createTestDb(
migrations = [...migrationsThrough0006, migration0007]
) {
const dir = mkdtempSync(join(tmpdir(), 'wg-easy-client-groups-'));
const client = createClient({ url: `file:${join(dir, 'test.db')}` });
await applyMigrations(client, migrations);
await client.execute('PRAGMA foreign_keys=ON');
const db = drizzle({ client, schema });
return { client, db };
}
async function seedUser(db: TestDb) {
await db.insert(schema.user).values({
id: 1,
username: 'admin',
password: 'hash',
email: null,
name: 'Administrator',
role: 2,
totpVerified: false,
enabled: true,
});
}
async function seedClient(
db: TestDb,
data: Partial<typeof schema.client.$inferInsert> = {}
) {
const [createdClient] = await db
.insert(schema.client)
.values({
userId: 1,
interfaceId: 'wg0',
name: data.name ?? 'Phone',
ipv4Address: data.ipv4Address ?? '10.8.0.2',
ipv6Address: data.ipv6Address ?? 'fdcc:ad94:bacf:61a4::cafe:2',
privateKey: data.privateKey ?? 'private',
publicKey: data.publicKey ?? 'public',
preSharedKey: data.preSharedKey ?? 'psk',
allowedIps: data.allowedIps ?? ['10.0.0.0/24'],
dns: data.dns ?? ['1.1.1.1'],
firewallIps: data.firewallIps ?? ['10.0.0.1:443/tcp'],
persistentKeepalive: data.persistentKeepalive ?? 0,
mtu: data.mtu ?? 1420,
serverAllowedIps: data.serverAllowedIps ?? [],
enabled: data.enabled ?? true,
})
.returning()
.execute();
if (!createdClient) {
throw new Error('Client was not created');
}
return createdClient;
}
async function seedExistingClientBefore0007(client: LibsqlClient) {
await client.execute({
sql: `INSERT INTO users_table
(id, username, password, email, name, role, totp_verified, enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
args: [1, 'admin', 'hash', null, 'Administrator', 2, 0, 1],
});
await client.execute({
sql: `INSERT INTO clients_table
(
id, user_id, interface_id, name, ipv4_address, ipv6_address,
pre_up, post_up, pre_down, post_down,
private_key, public_key, pre_shared_key, expires_at,
allowed_ips, server_allowed_ips, firewall_ips,
persistent_keepalive, mtu, j_c, j_min, j_max,
i1, i2, i3, i4, i5, dns, server_endpoint, enabled
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
args: [
7,
1,
'wg0',
'Migrated Phone',
'10.8.0.77',
'fdcc:ad94:bacf:61a4::cafe:77',
'pre-up',
'post-up',
'pre-down',
'post-down',
'private-existing',
'public-existing',
'psk-existing',
'2030-01-01T00:00:00.000Z',
JSON.stringify(['10.77.0.0/24']),
JSON.stringify(['192.168.77.0/24']),
JSON.stringify(['10.77.0.10:8443/tcp']),
25,
1280,
9,
11,
999,
'i1-value',
'i2-value',
'i3-value',
'i4-value',
'i5-value',
JSON.stringify(['9.9.9.9']),
'vpn.example.test',
0,
],
});
}
describe('ClientGroupService', () => {
let client: LibsqlClient;
let db: TestDb;
let service: ClientGroupService;
beforeEach(async () => {
const testDb = await createTestDb();
client = testDb.client;
db = testDb.db;
service = new ClientGroupService(db);
await seedUser(db);
});
afterEach(async () => {
await client.close();
});
test('creates, lists deterministically, gets, updates, and deletes client groups', async () => {
const secondGroup = await service.create({
name: 'Vendors',
description: 'External vendor devices',
allowedIps: ['10.20.0.0/24'],
dns: ['9.9.9.9'],
firewallIps: ['10.20.0.10:443/tcp'],
});
const firstGroup = await service.create({
name: 'Customers',
description: 'Production customer devices',
allowedIps: ['10.10.0.0/24'],
dns: ['1.1.1.1'],
firewallIps: ['10.10.0.10:443/tcp'],
});
expect(firstGroup.createdAt).toBeInstanceOf(Date);
expect(firstGroup.updatedAt).toBeInstanceOf(Date);
await expect(service.list()).resolves.toMatchObject([
{ id: firstGroup.id, name: 'Customers', assignedClientCount: 0 },
{ id: secondGroup.id, name: 'Vendors', assignedClientCount: 0 },
]);
await expect(service.get(firstGroup.id)).resolves.toMatchObject({
id: firstGroup.id,
assignedClientCount: 0,
});
await expect(service.get(9999)).resolves.toBeUndefined();
await expect(
service.update(firstGroup.id, {
name: 'Internal Staff',
description: null,
allowedIps: null,
dns: null,
firewallIps: [],
})
).resolves.toMatchObject({
id: firstGroup.id,
name: 'Internal Staff',
description: null,
allowedIps: null,
dns: null,
firewallIps: [],
});
await expect(
service.update(9999, {
name: 'Missing',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
})
).rejects.toThrow('Client group not found');
await service.delete(firstGroup.id);
await expect(service.get(firstGroup.id)).resolves.toBeUndefined();
});
test('assigns, unassigns, counts, and handles missing clients or groups', async () => {
const existingClient = await seedClient(db);
const group = await service.create({
name: 'Temporary Contractors',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
});
await service.assignClient(existingClient.id, group.id);
await expect(service.countAssignedClients(group.id)).resolves.toBe(1);
await expect(service.get(group.id)).resolves.toMatchObject({
assignedClientCount: 1,
});
await expect(db.query.client.findFirst()).resolves.toMatchObject({
groupId: group.id,
});
await expect(service.assignClient(9999, group.id)).rejects.toThrow(
'Client not found'
);
await expect(service.assignClient(existingClient.id, 9999)).rejects.toThrow(
'Client group not found'
);
await service.unassignClient(existingClient.id);
await expect(service.countAssignedClients(group.id)).resolves.toBe(0);
await expect(db.query.client.findFirst()).resolves.toMatchObject({
groupId: null,
});
await expect(service.unassignClient(9999)).resolves.toBeDefined();
});
test('deleting a group with assigned clients sets clients to unassigned', async () => {
const existingClient = await seedClient(db);
const group = await service.create({
name: 'External Vendor',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
});
await service.assignClient(existingClient.id, group.id);
await service.delete(group.id);
await expect(db.query.client.findFirst()).resolves.toMatchObject({
id: existingClient.id,
groupId: null,
name: 'Phone',
});
});
test('preserves null, empty, and non-empty group JSON values', async () => {
const nullGroup = await service.create({
name: 'Null Values',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
});
const emptyGroup = await service.create({
name: 'Empty Values',
description: null,
allowedIps: [],
dns: [],
firewallIps: [],
});
const configuredGroup = await service.create({
name: 'Configured Values',
description: null,
allowedIps: ['10.30.0.0/24'],
dns: ['8.8.8.8'],
firewallIps: ['10.30.0.10:443/tcp'],
});
await expect(service.get(nullGroup.id)).resolves.toMatchObject({
allowedIps: null,
dns: null,
firewallIps: null,
});
await expect(service.get(emptyGroup.id)).resolves.toMatchObject({
allowedIps: [],
dns: [],
firewallIps: [],
});
await expect(service.get(configuredGroup.id)).resolves.toMatchObject({
allowedIps: ['10.30.0.0/24'],
dns: ['8.8.8.8'],
firewallIps: ['10.30.0.10:443/tcp'],
});
await service.update(configuredGroup.id, {
name: 'Configured Values',
description: null,
allowedIps: [],
dns: [],
firewallIps: [],
});
await expect(service.get(configuredGroup.id)).resolves.toMatchObject({
allowedIps: [],
dns: [],
firewallIps: [],
});
});
test('trims names, rejects whitespace-only names, and rejects exact duplicates', async () => {
await expect(
service.create({
name: ' Customers ',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
})
).resolves.toMatchObject({ name: 'Customers' });
await expect(
service.create({
name: ' ',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
})
).rejects.toThrow();
await expect(
service.create({
name: 'Customers',
description: null,
allowedIps: null,
dns: null,
firewallIps: null,
})
).rejects.toThrow();
});
test('upgrades existing 0006 clients through migration 0007', async () => {
await client.close();
const testDb = await createTestDb(migrationsThrough0006);
client = testDb.client;
await seedExistingClientBefore0007(client);
await applyMigration(client, migration0007);
await client.execute('PRAGMA foreign_keys=ON');
db = drizzle({ client, schema });
service = new ClientGroupService(db);
const existingClient = await db.query.client.findFirst();
expect(existingClient).toMatchObject({
id: 7,
name: 'Migrated Phone',
ipv4Address: '10.8.0.77',
ipv6Address: 'fdcc:ad94:bacf:61a4::cafe:77',
preUp: 'pre-up',
postUp: 'post-up',
preDown: 'pre-down',
postDown: 'post-down',
privateKey: 'private-existing',
publicKey: 'public-existing',
preSharedKey: 'psk-existing',
expiresAt: '2030-01-01T00:00:00.000Z',
allowedIps: ['10.77.0.0/24'],
serverAllowedIps: ['192.168.77.0/24'],
firewallIps: ['10.77.0.10:8443/tcp'],
persistentKeepalive: 25,
mtu: 1280,
jC: 9,
jMin: 11,
jMax: 999,
i1: 'i1-value',
i2: 'i2-value',
i3: 'i3-value',
i4: 'i4-value',
i5: 'i5-value',
dns: ['9.9.9.9'],
serverEndpoint: 'vpn.example.test',
enabled: false,
groupId: null,
});
const group = await service.create({
name: 'Migrated Group',
description: null,
allowedIps: [],
dns: [],
firewallIps: [],
});
await service.assignClient(7, group.id);
await expect(db.query.client.findFirst()).resolves.toMatchObject({
id: 7,
groupId: group.id,
});
await service.delete(group.id);
await expect(db.query.client.findFirst()).resolves.toMatchObject({
id: 7,
name: 'Migrated Phone',
groupId: null,
});
});
});

1
src/vitest.config.ts

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

Loading…
Cancel
Save