From 53cff8beba40ac4a6dd594cdad8b58beb99d9b18 Mon Sep 17 00:00:00 2001 From: SPetrakis Date: Sat, 27 Jun 2026 18:37:14 +0300 Subject: [PATCH] feat(groups): add client group persistence foundation --- src/server/api/client/[clientId]/index.get.ts | 7 +- .../migrations/0007_amused_madame_web.sql | 14 + .../migrations/meta/0007_snapshot.json | 1127 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + .../database/repositories/client/schema.ts | 18 +- .../database/repositories/client/service.ts | 3 + .../database/repositories/client/types.ts | 19 +- .../repositories/clientGroup/schema.ts | 30 + .../repositories/clientGroup/service.ts | 183 +++ .../repositories/clientGroup/types.ts | 60 + src/server/database/schema.ts | 1 + src/server/database/sqlite.ts | 3 + src/test/unit/clientGroup.spec.ts | 444 +++++++ src/vitest.config.ts | 1 + 14 files changed, 1913 insertions(+), 4 deletions(-) create mode 100644 src/server/database/migrations/0007_amused_madame_web.sql create mode 100644 src/server/database/migrations/meta/0007_snapshot.json create mode 100644 src/server/database/repositories/clientGroup/schema.ts create mode 100644 src/server/database/repositories/clientGroup/service.ts create mode 100644 src/server/database/repositories/clientGroup/types.ts create mode 100644 src/test/unit/clientGroup.spec.ts diff --git a/src/server/api/client/[clientId]/index.get.ts b/src/server/api/client/[clientId]/index.get.ts index 87428de2..4dc8dcc4 100644 --- a/src/server/api/client/[clientId]/index.get.ts +++ b/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, }; } diff --git a/src/server/database/migrations/0007_amused_madame_web.sql b/src/server/database/migrations/0007_amused_madame_web.sql new file mode 100644 index 00000000..5e37ddbc --- /dev/null +++ b/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`); diff --git a/src/server/database/migrations/meta/0007_snapshot.json b/src/server/database/migrations/meta/0007_snapshot.json new file mode 100644 index 00000000..316d93e6 --- /dev/null +++ b/src/server/database/migrations/meta/0007_snapshot.json @@ -0,0 +1,1127 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1fe8563c-9832-4302-b960-377b190247ea", + "prevId": "8a38844d-02cb-4fce-8692-db05920c91a5", + "tables": { + "clients_table": { + "name": "clients_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "interface_id": { + "name": "interface_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv4_address": { + "name": "ipv4_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv6_address": { + "name": "ipv6_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_up": { + "name": "pre_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "post_up": { + "name": "post_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "pre_down": { + "name": "pre_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "post_down": { + "name": "post_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_shared_key": { + "name": "pre_shared_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_allowed_ips": { + "name": "server_allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firewall_ips": { + "name": "firewall_ips", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persistent_keepalive": { + "name": "persistent_keepalive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtu": { + "name": "mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "j_c": { + "name": "j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "j_min": { + "name": "j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "j_max": { + "name": "j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i1": { + "name": "i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i2": { + "name": "i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i3": { + "name": "i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i4": { + "name": "i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i5": { + "name": "i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dns": { + "name": "dns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "server_endpoint": { + "name": "server_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "clients_table_ipv4_address_unique": { + "name": "clients_table_ipv4_address_unique", + "columns": [ + "ipv4_address" + ], + "isUnique": true + }, + "clients_table_ipv6_address_unique": { + "name": "clients_table_ipv6_address_unique", + "columns": [ + "ipv6_address" + ], + "isUnique": true + }, + "clients_table_group_id_index": { + "name": "clients_table_group_id_index", + "columns": [ + "group_id" + ], + "isUnique": false + }, + "public_key_interface_unique": { + "name": "public_key_interface_unique", + "columns": [ + "public_key", + "interface_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "clients_table_user_id_users_table_id_fk": { + "name": "clients_table_user_id_users_table_id_fk", + "tableFrom": "clients_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "cascade" + }, + "clients_table_interface_id_interfaces_table_name_fk": { + "name": "clients_table_interface_id_interfaces_table_name_fk", + "tableFrom": "clients_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "interface_id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + }, + "clients_table_group_id_client_groups_table_id_fk": { + "name": "clients_table_group_id_client_groups_table_id_fk", + "tableFrom": "clients_table", + "tableTo": "client_groups_table", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "client_groups_table": { + "name": "client_groups_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dns": { + "name": "dns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "firewall_ips": { + "name": "firewall_ips", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "client_groups_table_name_unique": { + "name": "client_groups_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "general_table": { + "name": "general_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "setup_step": { + "name": "setup_step", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_password": { + "name": "session_password", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_timeout": { + "name": "session_timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_prometheus": { + "name": "metrics_prometheus", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_json": { + "name": "metrics_json", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metrics_password": { + "name": "metrics_password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "hooks_table": { + "name": "hooks_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "pre_up": { + "name": "pre_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_up": { + "name": "post_up", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pre_down": { + "name": "pre_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_down": { + "name": "post_down", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "hooks_table_id_interfaces_table_name_fk": { + "name": "hooks_table_id_interfaces_table_name_fk", + "tableFrom": "hooks_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "interfaces_table": { + "name": "interfaces_table", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv4_cidr": { + "name": "ipv4_cidr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ipv6_cidr": { + "name": "ipv6_cidr", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mtu": { + "name": "mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "j_c": { + "name": "j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 7 + }, + "j_min": { + "name": "j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "j_max": { + "name": "j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1000 + }, + "s1": { + "name": "s1", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 128 + }, + "s2": { + "name": "s2", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 56 + }, + "s3": { + "name": "s3", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "s4": { + "name": "s4", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h1": { + "name": "h1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h2": { + "name": "h2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h3": { + "name": "h3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "h4": { + "name": "h4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i1": { + "name": "i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i2": { + "name": "i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i3": { + "name": "i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i4": { + "name": "i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "i5": { + "name": "i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "firewall_enabled": { + "name": "firewall_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "interfaces_table_port_unique": { + "name": "interfaces_table_port_unique", + "columns": [ + "port" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "one_time_links_table": { + "name": "one_time_links_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "one_time_link": { + "name": "one_time_link", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "one_time_links_table_one_time_link_unique": { + "name": "one_time_links_table_one_time_link_unique", + "columns": [ + "one_time_link" + ], + "isUnique": true + } + }, + "foreignKeys": { + "one_time_links_table_id_clients_table_id_fk": { + "name": "one_time_links_table_id_clients_table_id_fk", + "tableFrom": "one_time_links_table", + "tableTo": "clients_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totp_key": { + "name": "totp_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_verified": { + "name": "totp_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oauth_provider": { + "name": "oauth_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_id": { + "name": "oauth_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "users_table_email_unique": { + "name": "users_table_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "oauth_provider_id_unique": { + "name": "oauth_provider_id_unique", + "columns": [ + "oauth_provider", + "oauth_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_configs_table": { + "name": "user_configs_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "default_mtu": { + "name": "default_mtu", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_persistent_keepalive": { + "name": "default_persistent_keepalive", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_dns": { + "name": "default_dns", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_allowed_ips": { + "name": "default_allowed_ips", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_j_c": { + "name": "default_j_c", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 7 + }, + "default_j_min": { + "name": "default_j_min", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 10 + }, + "default_j_max": { + "name": "default_j_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1000 + }, + "default_i1": { + "name": "default_i1", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i2": { + "name": "default_i2", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i3": { + "name": "default_i3", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i4": { + "name": "default_i4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_i5": { + "name": "default_i5", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "host": { + "name": "host", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "port": { + "name": "port", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": {}, + "foreignKeys": { + "user_configs_table_id_interfaces_table_name_fk": { + "name": "user_configs_table_id_interfaces_table_name_fk", + "tableFrom": "user_configs_table", + "tableTo": "interfaces_table", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "name" + ], + "onDelete": "cascade", + "onUpdate": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/server/database/migrations/meta/_journal.json b/src/server/database/migrations/meta/_journal.json index 0ae8c800..19b3d212 100644 --- a/src/server/database/migrations/meta/_journal.json +++ b/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 } ] } \ No newline at end of file diff --git a/src/server/database/repositories/client/schema.ts b/src/server/database/repositories/client/schema.ts index eb3a707e..0cf8365c 100644 --- a/src/server/database/repositories/client/schema.ts +++ b/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(), serverAllowedIps: text('server_allowed_ips', { mode: 'json' }) .$type() @@ -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], diff --git a/src/server/database/repositories/client/service.ts b/src/server/database/repositories/client/service.ts index 14b664b1..513c828c 100644 --- a/src/server/database/repositories/client/service.ts +++ b/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, }, diff --git a/src/server/database/repositories/client/types.ts b/src/server/database/repositories/client/types.ts index f7497e31..6460c607 100644 --- a/src/server/database/repositories/client/types.ts +++ b/src/server/database/repositories/client/types.ts @@ -25,6 +25,18 @@ import { export type ClientType = InferSelectModel; +export const CurrentPublicClientColumns = { + groupId: false, +} as const; + +export function toCurrentPublicClient( + client: T +) { + const { groupId: _groupId, ...publicClient } = client; + + return publicClient; +} + export type ClientNextIpType = Pick; 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 diff --git a/src/server/database/repositories/clientGroup/schema.ts b/src/server/database/repositories/clientGroup/schema.ts new file mode 100644 index 00000000..f1bc3b20 --- /dev/null +++ b/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(), + dns: text({ mode: 'json' }).$type(), + 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), +})); diff --git a/src/server/database/repositories/clientGroup/service.ts b/src/server/database/repositories/clientGroup/service.ts new file mode 100644 index 00000000..075b63fc --- /dev/null +++ b/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; + + 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 { + 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 { + 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)); + } +} diff --git a/src/server/database/repositories/clientGroup/types.ts b/src/server/database/repositories/clientGroup/types.ts new file mode 100644 index 00000000..b241e935 --- /dev/null +++ b/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; + +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()( + z.object({ + name, + description, + allowedIps: z.array(AddressSchema).nullable(), + dns: DnsSchema.nullable(), + firewallIps: FirewallIpsSchema.nullable(), + }) +); + +export const ClientGroupUpdateSchema = ClientGroupCreateSchema; diff --git a/src/server/database/schema.ts b/src/server/database/schema.ts index 8527bcfa..4c3daa3f 100644 --- a/src/server/database/schema.ts +++ b/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'; diff --git a/src/server/database/sqlite.ts b/src/server/database/sqlite.ts index 59469ccc..81bd5559 100644 --- a/src/server/database/sqlite.ts +++ b/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); diff --git a/src/test/unit/clientGroup.spec.ts b/src/test/unit/clientGroup.spec.ts new file mode 100644 index 00000000..afa88ffe --- /dev/null +++ b/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; +type TestDb = ReturnType>; + +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 = {} +) { + 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, + }); + }); +}); diff --git a/src/vitest.config.ts b/src/vitest.config.ts index 3a37baa0..a951cfb5 100644 --- a/src/vitest.config.ts +++ b/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)), }, },