wireguard-ui/handler/api_v1_clients_test.go

1117 lines
35 KiB
Go

package handler
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/labstack/echo/v4"
"github.com/rs/xid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/DigitalTolk/wireguard-ui/emailer"
"github.com/DigitalTolk/wireguard-ui/model"
)
// mockEmailer implements the emailer.Emailer interface for testing
type mockEmailer struct {
sendCalled bool
lastTo string
shouldFail bool
}
func (m *mockEmailer) Send(toName, to, subject, content string, attachments []emailer.Attachment) error {
m.sendCalled = true
m.lastTo = to
if m.shouldFail {
return fmt.Errorf("email send failed")
}
return nil
}
func makeClient(id string) model.Client {
now := time.Now().UTC()
return model.Client{
ID: id, Name: "Client " + id, AllocatedIPs: []string{}, AllowedIPs: []string{},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
}
}
func TestAPIListClients(t *testing.T) {
env := setupTestEnv(t)
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: xid.New().String(), Name: "Client 1", AllocatedIPs: []string{"10.252.1.2/32"},
AllowedIPs: []string{"0.0.0.0/0"}, ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients", nil)
c := env.echo.NewContext(req, rec)
err := APIListClients(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var clients []model.ClientData
parseJSON(t, rec, &clients)
assert.Len(t, clients, 1)
assert.Equal(t, "Client 1", clients[0].Client.Name)
}
func TestAPIGetClient(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
env.db.SaveClient(makeClient(id))
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients/"+id, nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIGetClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestAPIGetClient_InvalidID(t *testing.T) {
env := setupTestEnv(t)
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients/bad", nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("bad!")
err := APIGetClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPIGetClient_NotFound(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients/"+id, nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIGetClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestAPIDeleteClient(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
env.db.SaveClient(makeClient(id))
req, rec := jsonRequest(http.MethodDelete, "/api/v1/clients/"+id, nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIDeleteClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)
_, getErr := env.db.GetClientByID(id, model.QRCodeSettings{})
assert.Error(t, getErr)
}
func TestAPIPatchClientStatus(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
env.db.SaveClient(makeClient(id))
req, rec := jsonRequest(http.MethodPatch, "/api/v1/clients/"+id+"/status",
map[string]bool{"enabled": false})
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIPatchClientStatus(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
got, _ := env.db.GetClientByID(id, model.QRCodeSettings{})
assert.False(t, got.Client.Enabled)
}
func TestAPISuggestClientIPs(t *testing.T) {
env := setupTestEnv(t)
req, rec := jsonRequest(http.MethodGet, "/api/v1/suggest-client-ips", nil)
c := env.echo.NewContext(req, rec)
err := APISuggestClientIPs(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var ips []string
parseJSON(t, rec, &ips)
assert.NotEmpty(t, ips)
}
func TestAPIMachineIPs(t *testing.T) {
req, rec := jsonRequest(http.MethodGet, "/api/v1/machine-ips", nil)
e := echo.New()
c := e.NewContext(req, rec)
err := APIMachineIPs()(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestAPISubnetRanges(t *testing.T) {
req, rec := jsonRequest(http.MethodGet, "/api/v1/subnet-ranges", nil)
e := echo.New()
c := e.NewContext(req, rec)
err := APISubnetRanges()(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
}
func TestAPIConfigStatus(t *testing.T) {
env := setupTestEnv(t)
req, rec := jsonRequest(http.MethodGet, "/api/v1/server/config-status", nil)
c := env.echo.NewContext(req, rec)
err := APIConfigStatus(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]bool
parseJSON(t, rec, &result)
_, hasChanged := result["changed"]
assert.True(t, hasChanged)
}
// --- APICreateClient Tests ---
func TestAPICreateClient_Success(t *testing.T) {
env := setupTestEnv(t)
body := map[string]interface{}{
"name": "New Client",
"email": "client@test.com",
"allocated_ips": []string{"10.252.1.50/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
"use_server_dns": true,
"enabled": true,
}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients", body)
c := env.echo.NewContext(req, rec)
err := APICreateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
var client model.Client
parseJSON(t, rec, &client)
assert.Equal(t, "New Client", client.Name)
assert.NotEmpty(t, client.ID)
assert.NotEmpty(t, client.PublicKey)
assert.NotEmpty(t, client.PrivateKey)
assert.NotEmpty(t, client.PresharedKey)
}
func TestAPICreateClient_InvalidAllocatedIPs(t *testing.T) {
env := setupTestEnv(t)
body := map[string]interface{}{
"name": "Bad IPs",
"email": "bad@test.com",
"allocated_ips": []string{"192.168.99.1/32"}, // outside server range
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients", body)
c := env.echo.NewContext(req, rec)
err := APICreateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPICreateClient_InvalidAllowedIPs(t *testing.T) {
env := setupTestEnv(t)
body := map[string]interface{}{
"name": "Bad Allowed",
"email": "bad@test.com",
"allocated_ips": []string{"10.252.1.50/32"},
"allowed_ips": []string{"not-a-cidr"},
"extra_allowed_ips": []string{},
}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients", body)
c := env.echo.NewContext(req, rec)
err := APICreateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPICreateClient_InvalidExtraAllowedIPs(t *testing.T) {
env := setupTestEnv(t)
body := map[string]interface{}{
"name": "Bad Extra",
"email": "bad@test.com",
"allocated_ips": []string{"10.252.1.50/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{"not-a-cidr"},
}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients", body)
c := env.echo.NewContext(req, rec)
err := APICreateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPICreateClient_WithPresharedKeyDash(t *testing.T) {
env := setupTestEnv(t)
body := map[string]interface{}{
"name": "No PSK",
"email": "nopsk@test.com",
"allocated_ips": []string{"10.252.1.51/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
"preshared_key": "-",
"enabled": true,
}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients", body)
c := env.echo.NewContext(req, rec)
err := APICreateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
var client model.Client
parseJSON(t, rec, &client)
assert.Empty(t, client.PresharedKey)
}
func TestAPICreateClient_DuplicateAllocatedIP(t *testing.T) {
env := setupTestEnv(t)
// First, create a client
body1 := map[string]interface{}{
"name": "First",
"email": "first@test.com",
"allocated_ips": []string{"10.252.1.60/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
"enabled": true,
}
req1, rec1 := jsonRequest(http.MethodPost, "/api/v1/clients", body1)
c1 := env.echo.NewContext(req1, rec1)
err := APICreateClient(env.db)(c1)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec1.Code)
// Try to create another with same IP
body2 := map[string]interface{}{
"name": "Second",
"email": "second@test.com",
"allocated_ips": []string{"10.252.1.60/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
}
req2, rec2 := jsonRequest(http.MethodPost, "/api/v1/clients", body2)
c2 := env.echo.NewContext(req2, rec2)
err = APICreateClient(env.db)(c2)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec2.Code)
}
// --- APIUpdateClient Tests ---
func TestAPIUpdateClient_Success(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Original", Email: "original@test.com", PublicKey: "origpub", PrivateKey: "origpriv",
AllocatedIPs: []string{"10.252.1.70/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
body := map[string]interface{}{
"name": "Updated",
"email": "try-change@test.com",
"allocated_ips": []string{"10.252.1.70/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
"public_key": "origpub",
"preshared_key": "",
"enabled": true,
"use_server_dns": false,
}
req, rec := jsonRequest(http.MethodPut, "/api/v1/clients/"+id, body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIUpdateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var client model.Client
parseJSON(t, rec, &client)
assert.Equal(t, "Updated", client.Name)
// email is immutable — original preserved, attempted change ignored
assert.Equal(t, "original@test.com", client.Email)
}
func TestAPIUpdateClient_InvalidID(t *testing.T) {
env := setupTestEnv(t)
body := map[string]interface{}{"name": "test"}
req, rec := jsonRequest(http.MethodPut, "/api/v1/clients/bad", body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("bad!")
err := APIUpdateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPIUpdateClient_NotFound(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
body := map[string]interface{}{
"name": "Update",
"allocated_ips": []string{"10.252.1.70/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
}
req, rec := jsonRequest(http.MethodPut, "/api/v1/clients/"+id, body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIUpdateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestAPIUpdateClient_InvalidAllowedIPs(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Orig", PublicKey: "pub1",
AllocatedIPs: []string{"10.252.1.71/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
body := map[string]interface{}{
"name": "Bad update",
"allocated_ips": []string{"10.252.1.71/32"},
"allowed_ips": []string{"not-cidr"},
"extra_allowed_ips": []string{},
"public_key": "pub1",
}
req, rec := jsonRequest(http.MethodPut, "/api/v1/clients/"+id, body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIUpdateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
// --- APIDownloadClientConfig Tests ---
func TestAPIDownloadClientConfig_Success(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Download Client", PublicKey: "dlpub", PrivateKey: "dlpriv",
AllocatedIPs: []string{"10.252.1.80/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, UseServerDNS: true, CreatedAt: now, UpdatedAt: now,
})
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients/"+id+"/config", nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIDownloadClientConfig(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Body.String(), "# CONFIDENTIAL")
assert.Contains(t, rec.Body.String(), "[Interface]")
assert.Contains(t, rec.Body.String(), "PrivateKey = dlpriv")
assert.Contains(t, rec.Header().Get(echo.HeaderContentDisposition), "Download Client.conf")
}
func TestAPIDownloadClientConfig_InvalidID(t *testing.T) {
env := setupTestEnv(t)
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients/bad/config", nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("bad!")
err := APIDownloadClientConfig(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPIDownloadClientConfig_NotFound(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients/"+id+"/config", nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIDownloadClientConfig(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
// --- APIGetClientQRCode Tests ---
func TestAPIGetClientQRCode_Success(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "QR Client", PublicKey: "qrpub", PrivateKey: "qrpriv",
AllocatedIPs: []string{"10.252.1.81/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients/"+id+"/qrcode", nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIGetClientQRCode(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var result map[string]string
parseJSON(t, rec, &result)
assert.NotEmpty(t, result["qr_code"])
assert.Contains(t, result["qr_code"], "data:image/png;base64,")
}
func TestAPIGetClientQRCode_InvalidID(t *testing.T) {
env := setupTestEnv(t)
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients/bad/qrcode", nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("bad!")
err := APIGetClientQRCode(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPIGetClientQRCode_NotFound(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients/"+id+"/qrcode", nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIGetClientQRCode(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
// --- APIApplyServerConfig Tests ---
func TestAPIApplyServerConfig_Success(t *testing.T) {
env := setupTestEnv(t)
// Set config file path to a temp location
tmpDir := t.TempDir()
gs, err := env.db.GetGlobalSettings()
require.NoError(t, err)
gs.ConfigFilePath = tmpDir + "/wg0.conf"
require.NoError(t, env.db.SaveGlobalSettings(gs))
tmplFS := os.DirFS("../templates")
req, rec := jsonRequest(http.MethodPost, "/api/v1/server/apply-config", nil)
c := env.echo.NewContext(req, rec)
err = APIApplyServerConfig(env.db, tmplFS)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
// Verify the file was created
_, statErr := os.Stat(tmpDir + "/wg0.conf")
assert.NoError(t, statErr)
}
// --- APIPatchClientStatus edge cases ---
func TestAPIPatchClientStatus_InvalidID(t *testing.T) {
env := setupTestEnv(t)
req, rec := jsonRequest(http.MethodPatch, "/api/v1/clients/bad/status",
map[string]bool{"enabled": true})
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("bad!")
err := APIPatchClientStatus(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPIPatchClientStatus_NotFound(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
req, rec := jsonRequest(http.MethodPatch, "/api/v1/clients/"+id+"/status",
map[string]bool{"enabled": true})
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIPatchClientStatus(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestAPIPatchClientStatus_Enable(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Disabled", PublicKey: "pub1",
AllocatedIPs: []string{}, AllowedIPs: []string{},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: false, CreatedAt: now, UpdatedAt: now,
})
req, rec := jsonRequest(http.MethodPatch, "/api/v1/clients/"+id+"/status",
map[string]bool{"enabled": true})
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIPatchClientStatus(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
got, _ := env.db.GetClientByID(id, model.QRCodeSettings{})
assert.True(t, got.Client.Enabled)
}
// --- APIDeleteClient edge cases ---
func TestAPIDeleteClient_InvalidID(t *testing.T) {
env := setupTestEnv(t)
req, rec := jsonRequest(http.MethodDelete, "/api/v1/clients/bad", nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("bad!")
err := APIDeleteClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
// --- APIEmailClient Tests ---
func TestAPIEmailClient_Success(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Email Client", PublicKey: "emailpub", PrivateKey: "emailpriv",
AllocatedIPs: []string{"10.252.1.90/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
mailer := &mockEmailer{}
body := map[string]string{"email": "recipient@test.com"}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients/"+id+"/email", body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIEmailClient(env.db, mailer, "WireGuard Config", "Your config is attached.")(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, mailer.sendCalled)
assert.Equal(t, "recipient@test.com", mailer.lastTo)
}
func TestAPIEmailClient_InvalidID(t *testing.T) {
env := setupTestEnv(t)
mailer := &mockEmailer{}
body := map[string]string{"email": "test@test.com"}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients/bad/email", body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues("bad!")
err := APIEmailClient(env.db, mailer, "", "")(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.False(t, mailer.sendCalled)
}
func TestAPIEmailClient_NotFound(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
mailer := &mockEmailer{}
body := map[string]string{"email": "test@test.com"}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients/"+id+"/email", body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIEmailClient(env.db, mailer, "", "")(c)
require.NoError(t, err)
assert.Equal(t, http.StatusNotFound, rec.Code)
}
func TestAPIEmailClient_SendFailure(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Fail Client", PublicKey: "fpub", PrivateKey: "fpriv",
AllocatedIPs: []string{"10.252.1.91/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
mailer := &mockEmailer{shouldFail: true}
body := map[string]string{"email": "fail@test.com"}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients/"+id+"/email", body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIEmailClient(env.db, mailer, "Subject", "Body")(c)
require.NoError(t, err)
assert.Equal(t, http.StatusInternalServerError, rec.Code)
}
func TestAPIEmailClient_NoPrivateKey(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
// Client with no private key (external public key provided)
env.db.SaveClient(model.Client{
ID: id, Name: "NoPK Client", PublicKey: "nopkpub", PrivateKey: "",
AllocatedIPs: []string{"10.252.1.92/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
mailer := &mockEmailer{}
body := map[string]string{"email": "nopk@test.com"}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients/"+id+"/email", body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIEmailClient(env.db, mailer, "Subject", "Body")(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
assert.True(t, mailer.sendCalled)
}
// --- APIUpdateClient extra edge cases ---
func TestAPIUpdateClient_InvalidExtraAllowedIPs(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Orig", PublicKey: "pub1",
AllocatedIPs: []string{"10.252.1.73/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
body := map[string]interface{}{
"name": "Bad extra",
"allocated_ips": []string{"10.252.1.73/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{"not-cidr"},
"public_key": "pub1",
}
req, rec := jsonRequest(http.MethodPut, "/api/v1/clients/"+id, body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIUpdateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
// --- APICreateClient with provided public key ---
func TestAPICreateClient_WithProvidedPublicKey(t *testing.T) {
env := setupTestEnv(t)
// Generate a real key pair for a valid public key
body := map[string]interface{}{
"name": "External Key Client",
"email": "ext@test.com",
"allocated_ips": []string{"10.252.1.52/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
"public_key": "YW52YWxpZGtleWJ1dGVub3VnaGJ5dGVzYWF6enp6eg==", // valid base64 but not a valid WG key
"enabled": true,
}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients", body)
c := env.echo.NewContext(req, rec)
err := APICreateClient(env.db)(c)
require.NoError(t, err)
// Invalid WG key should return bad request
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPICreateClient_WithInvalidPresharedKey(t *testing.T) {
env := setupTestEnv(t)
body := map[string]interface{}{
"name": "Bad PSK",
"email": "psk@test.com",
"allocated_ips": []string{"10.252.1.53/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
"preshared_key": "not-a-valid-key",
"enabled": true,
}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients", body)
c := env.echo.NewContext(req, rec)
err := APICreateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
// --- APIUpdateClient with changed public key ---
func TestAPIUpdateClient_ChangePublicKey_Invalid(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Orig", PublicKey: "origpub", PrivateKey: "origpriv",
AllocatedIPs: []string{"10.252.1.74/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
body := map[string]interface{}{
"name": "Change Key",
"allocated_ips": []string{"10.252.1.74/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
"public_key": "invalid-new-key",
"enabled": true,
}
req, rec := jsonRequest(http.MethodPut, "/api/v1/clients/"+id, body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIUpdateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPIUpdateClient_ChangePresharedKey_Invalid(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Orig", PublicKey: "origpub", PresharedKey: "origpsk",
AllocatedIPs: []string{"10.252.1.75/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
body := map[string]interface{}{
"name": "Change PSK",
"allocated_ips": []string{"10.252.1.75/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
"public_key": "origpub",
"preshared_key": "invalid-psk",
"enabled": true,
}
req, rec := jsonRequest(http.MethodPut, "/api/v1/clients/"+id, body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIUpdateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPICreateClient_InvalidBody(t *testing.T) {
env := setupTestEnv(t)
// Send non-JSON body with JSON content-type to trigger bind error
req := httptest.NewRequest(http.MethodPost, "/api/v1/clients", strings.NewReader("{invalid json"))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := env.echo.NewContext(req, rec)
err := APICreateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPIUpdateClient_InvalidBody(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Orig", PublicKey: "pub1",
AllocatedIPs: []string{"10.252.1.77/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
req := httptest.NewRequest(http.MethodPut, "/api/v1/clients/"+id, strings.NewReader("{invalid"))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIUpdateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPIPatchClientStatus_InvalidBody(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Patch", PublicKey: "pub1",
AllocatedIPs: []string{}, AllowedIPs: []string{},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
req := httptest.NewRequest(http.MethodPatch, "/api/v1/clients/"+id+"/status", strings.NewReader("{invalid"))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIPatchClientStatus(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPIEmailClient_InvalidBody(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Bad Email Body", PublicKey: "pub1",
AllocatedIPs: []string{"10.252.1.94/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
mailer := &mockEmailer{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/clients/"+id+"/email", strings.NewReader("{invalid"))
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
rec := httptest.NewRecorder()
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIEmailClient(env.db, mailer, "", "")(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAPIUpdateClient_InvalidAllocatedIPs(t *testing.T) {
env := setupTestEnv(t)
id := xid.New().String()
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: id, Name: "Orig", PublicKey: "pub1",
AllocatedIPs: []string{"10.252.1.76/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
body := map[string]interface{}{
"name": "Bad allocated",
"allocated_ips": []string{"192.168.99.1/32"}, // outside server range
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
"public_key": "pub1",
}
req, rec := jsonRequest(http.MethodPut, "/api/v1/clients/"+id, body)
c := env.echo.NewContext(req, rec)
c.SetParamNames("id")
c.SetParamValues(id)
err := APIUpdateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
}
// --- APICreateClient email validation ---
func TestAPICreateClient_MissingEmail(t *testing.T) {
env := setupTestEnv(t)
body := map[string]interface{}{
"name": "No Email",
"allocated_ips": []string{"10.252.1.54/32"},
"allowed_ips": []string{"0.0.0.0/0"},
"extra_allowed_ips": []string{},
"enabled": true,
}
req, rec := jsonRequest(http.MethodPost, "/api/v1/clients", body)
c := env.echo.NewContext(req, rec)
err := APICreateClient(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Contains(t, rec.Body.String(), "Email is required")
}
// --- APIListClients search and status filtering ---
func TestAPIListClients_SearchFilter(t *testing.T) {
env := setupTestEnv(t)
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: xid.New().String(), Name: "Alice Laptop", Email: "alice@test.com",
AllocatedIPs: []string{"10.252.1.30/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
env.db.SaveClient(model.Client{
ID: xid.New().String(), Name: "Bob Phone", Email: "bob@test.com",
AllocatedIPs: []string{"10.252.1.31/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: false, CreatedAt: now, UpdatedAt: now,
})
// search by name
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients?search=alice", nil)
c := env.echo.NewContext(req, rec)
c.SetParamNames()
c.QueryParams().Set("search", "alice")
err := APIListClients(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
var clients []model.ClientData
parseJSON(t, rec, &clients)
assert.Len(t, clients, 1)
assert.Equal(t, "Alice Laptop", clients[0].Client.Name)
}
func TestAPIListClients_StatusFilter(t *testing.T) {
env := setupTestEnv(t)
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: xid.New().String(), Name: "Enabled Client", Email: "en@test.com",
AllocatedIPs: []string{"10.252.1.32/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
env.db.SaveClient(model.Client{
ID: xid.New().String(), Name: "Disabled Client", Email: "dis@test.com",
AllocatedIPs: []string{"10.252.1.33/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: false, CreatedAt: now, UpdatedAt: now,
})
// filter enabled only
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients?status=enabled", nil)
c := env.echo.NewContext(req, rec)
c.QueryParams().Set("status", "enabled")
err := APIListClients(env.db)(c)
require.NoError(t, err)
var enabledClients []model.ClientData
parseJSON(t, rec, &enabledClients)
assert.Len(t, enabledClients, 1)
assert.Equal(t, "Enabled Client", enabledClients[0].Client.Name)
// filter disabled only
req2, rec2 := jsonRequest(http.MethodGet, "/api/v1/clients?status=disabled", nil)
c2 := env.echo.NewContext(req2, rec2)
c2.QueryParams().Set("status", "disabled")
err = APIListClients(env.db)(c2)
require.NoError(t, err)
var disabledClients []model.ClientData
parseJSON(t, rec2, &disabledClients)
assert.Len(t, disabledClients, 1)
assert.Equal(t, "Disabled Client", disabledClients[0].Client.Name)
}
// --- APIExportClients ---
func TestAPIExportClients(t *testing.T) {
env := setupTestEnv(t)
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: xid.New().String(), Name: "Export Client", Email: "export@test.com",
AllocatedIPs: []string{"10.252.1.40/32"}, AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
req, rec := jsonRequest(http.MethodGet, "/api/v1/clients/export", nil)
c := env.echo.NewContext(req, rec)
err := APIExportClients(env.db)(c)
require.NoError(t, err)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Contains(t, rec.Header().Get("Content-Disposition"), "clients.xlsx")
assert.Contains(t, rec.Header().Get("Content-Type"), "spreadsheetml.sheet")
assert.True(t, rec.Body.Len() > 0)
}
// --- APIServerStatus ---
func TestAPIServerStatus(t *testing.T) {
env := setupTestEnv(t)
// Add some clients to exercise the peer matching logic
now := time.Now().UTC()
env.db.SaveClient(model.Client{
ID: xid.New().String(), Name: "Status Client",
PublicKey: "statuspub", PrivateKey: "statuspriv",
AllocatedIPs: []string{"10.252.1.95/32"},
AllowedIPs: []string{"0.0.0.0/0"},
ExtraAllowedIPs: []string{}, SubnetRanges: []string{},
Enabled: true, CreatedAt: now, UpdatedAt: now,
})
req, rec := jsonRequest(http.MethodGet, "/api/v1/status", nil)
c := env.echo.NewContext(req, rec)
err := APIServerStatus(env.db)(c)
require.NoError(t, err)
// On systems with WireGuard support, wgctrl.New() succeeds and returns 200
// On systems without, it returns 500
assert.Contains(t, []int{http.StatusOK, http.StatusInternalServerError}, rec.Code)
}