Add comprehensive test suite
This commit is contained in:
6
go.mod
6
go.mod
@@ -3,15 +3,17 @@ module git.dws.rip/DWS/dyn
|
|||||||
go 1.24.4
|
go 1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/TwiN/go-away v1.8.1
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/mattn/go-sqlite3 v1.14.24
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/TwiN/go-away v1.8.1 // indirect
|
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
@@ -26,6 +28,7 @@ require (
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
@@ -40,4 +43,5 @@ require (
|
|||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.30.0 // indirect
|
||||||
golang.org/x/tools v0.37.0 // indirect
|
golang.org/x/tools v0.37.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.9 // indirect
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
15
go.sum
15
go.sum
@@ -69,37 +69,24 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
|||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
|
||||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
|
||||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
|
||||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
|
||||||
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
|
||||||
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
|
||||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
|
||||||
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||||
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
|
||||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
|
||||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
|
||||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
|
||||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
|
||||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|||||||
372
internal/config/config_test.go
Normal file
372
internal/config/config_test.go
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoad(t *testing.T) {
|
||||||
|
// Clean up any existing env vars
|
||||||
|
cleanup := cleanEnv()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Set test values
|
||||||
|
os.Setenv("TECHNITIUM_URL", "https://test.dns.example.com")
|
||||||
|
os.Setenv("TECHNITIUM_TOKEN", "test-token-12345")
|
||||||
|
os.Setenv("BASE_DOMAIN", "test.rip")
|
||||||
|
os.Setenv("SPACE_SUBDOMAIN", "dyn")
|
||||||
|
os.Setenv("RATE_LIMIT_PER_IP", "20")
|
||||||
|
os.Setenv("RATE_LIMIT_PER_TOKEN", "5")
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"TechnitiumURL", cfg.TechnitiumURL, "https://test.dns.example.com"},
|
||||||
|
{"TechnitiumToken", cfg.TechnitiumToken, "test-token-12345"},
|
||||||
|
{"BaseDomain", cfg.BaseDomain, "test.rip"},
|
||||||
|
{"SpaceSubdomain", cfg.SpaceSubdomain, "dyn"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.got != tt.expected {
|
||||||
|
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test numeric values
|
||||||
|
if cfg.RateLimitPerIP != 20 {
|
||||||
|
t.Errorf("RateLimitPerIP = %v, want 20", cfg.RateLimitPerIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RateLimitPerToken != 5 {
|
||||||
|
t.Errorf("RateLimitPerToken = %v, want 5", cfg.RateLimitPerToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadDefaults(t *testing.T) {
|
||||||
|
cleanup := cleanEnv()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
cfg := Load()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
got string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"ServerPort", cfg.ServerPort, "8080"},
|
||||||
|
{"DatabasePath", cfg.DatabasePath, "./dyn.db"},
|
||||||
|
{"BaseDomain", cfg.BaseDomain, "dws.rip"},
|
||||||
|
{"SpaceSubdomain", cfg.SpaceSubdomain, "space"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.got != tt.expected {
|
||||||
|
t.Errorf("%s = %v, want %v", tt.name, tt.got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test default numeric values
|
||||||
|
if cfg.RateLimitPerIP != 10 {
|
||||||
|
t.Errorf("RateLimitPerIP default = %v, want 10", cfg.RateLimitPerIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RateLimitPerToken != 1 {
|
||||||
|
t.Errorf("RateLimitPerToken default = %v, want 1", cfg.RateLimitPerToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupEnv func()
|
||||||
|
wantErrors int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid config with token",
|
||||||
|
setupEnv: func() {
|
||||||
|
os.Setenv("TECHNITIUM_URL", "https://dns.example.com")
|
||||||
|
os.Setenv("TECHNITIUM_TOKEN", "valid-token")
|
||||||
|
},
|
||||||
|
wantErrors: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid config with username/password",
|
||||||
|
setupEnv: func() {
|
||||||
|
os.Setenv("TECHNITIUM_URL", "https://dns.example.com")
|
||||||
|
os.Setenv("TECHNITIUM_USERNAME", "admin")
|
||||||
|
os.Setenv("TECHNITIUM_PASSWORD", "secret")
|
||||||
|
os.Unsetenv("TECHNITIUM_TOKEN")
|
||||||
|
},
|
||||||
|
wantErrors: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing url",
|
||||||
|
setupEnv: func() {
|
||||||
|
os.Unsetenv("TECHNITIUM_URL")
|
||||||
|
os.Setenv("TECHNITIUM_TOKEN", "token")
|
||||||
|
},
|
||||||
|
wantErrors: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing auth",
|
||||||
|
setupEnv: func() {
|
||||||
|
os.Setenv("TECHNITIUM_URL", "https://dns.example.com")
|
||||||
|
os.Unsetenv("TECHNITIUM_TOKEN")
|
||||||
|
os.Unsetenv("TECHNITIUM_USERNAME")
|
||||||
|
os.Unsetenv("TECHNITIUM_PASSWORD")
|
||||||
|
},
|
||||||
|
wantErrors: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing everything",
|
||||||
|
setupEnv: func() {
|
||||||
|
os.Unsetenv("TECHNITIUM_URL")
|
||||||
|
os.Unsetenv("TECHNITIUM_TOKEN")
|
||||||
|
os.Unsetenv("TECHNITIUM_USERNAME")
|
||||||
|
os.Unsetenv("TECHNITIUM_PASSWORD")
|
||||||
|
},
|
||||||
|
wantErrors: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cleanup := cleanEnv()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
tt.setupEnv()
|
||||||
|
cfg := Load()
|
||||||
|
errors := cfg.Validate()
|
||||||
|
|
||||||
|
if len(errors) != tt.wantErrors {
|
||||||
|
t.Errorf("Validate() returned %d errors, want %d: %v", len(errors), tt.wantErrors, errors)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetZone(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
base string
|
||||||
|
space string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "with space subdomain",
|
||||||
|
base: "dws.rip",
|
||||||
|
space: "space",
|
||||||
|
expected: "space.dws.rip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "without space subdomain",
|
||||||
|
base: "dws.rip",
|
||||||
|
space: "",
|
||||||
|
expected: "dws.rip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nested subdomain",
|
||||||
|
base: "example.com",
|
||||||
|
space: "dyn.space",
|
||||||
|
expected: "dyn.space.example.com",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &Config{
|
||||||
|
BaseDomain: tt.base,
|
||||||
|
SpaceSubdomain: tt.space,
|
||||||
|
}
|
||||||
|
|
||||||
|
got := cfg.GetZone()
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("GetZone() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEnv(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
defaultValue string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "env set",
|
||||||
|
key: "TEST_KEY",
|
||||||
|
value: "test-value",
|
||||||
|
defaultValue: "default",
|
||||||
|
expected: "test-value",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env not set",
|
||||||
|
key: "UNSET_TEST_KEY",
|
||||||
|
value: "",
|
||||||
|
defaultValue: "default",
|
||||||
|
expected: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.value != "" {
|
||||||
|
os.Setenv(tt.key, tt.value)
|
||||||
|
defer os.Unsetenv(tt.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := getEnv(tt.key, tt.defaultValue)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("getEnv() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEnvAsInt(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
defaultValue int
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid int",
|
||||||
|
value: "42",
|
||||||
|
defaultValue: 10,
|
||||||
|
expected: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid int",
|
||||||
|
value: "not-a-number",
|
||||||
|
defaultValue: 10,
|
||||||
|
expected: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty value",
|
||||||
|
value: "",
|
||||||
|
defaultValue: 10,
|
||||||
|
expected: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative int",
|
||||||
|
value: "-5",
|
||||||
|
defaultValue: 10,
|
||||||
|
expected: -5,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
key := "TEST_INT_KEY"
|
||||||
|
if tt.value != "" {
|
||||||
|
os.Setenv(key, tt.value)
|
||||||
|
defer os.Unsetenv(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := getEnvAsInt(key, tt.defaultValue)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("getEnvAsInt() = %v, want %v", got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEnvAsSlice(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
defaultValue []string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single value",
|
||||||
|
value: "10.0.0.0/8",
|
||||||
|
defaultValue: []string{},
|
||||||
|
expected: []string{"10.0.0.0/8"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple values",
|
||||||
|
value: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16",
|
||||||
|
defaultValue: []string{},
|
||||||
|
expected: []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty value",
|
||||||
|
value: "",
|
||||||
|
defaultValue: []string{"default"},
|
||||||
|
expected: []string{"default"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
key := "TEST_SLICE_KEY"
|
||||||
|
if tt.value != "" {
|
||||||
|
os.Setenv(key, tt.value)
|
||||||
|
defer os.Unsetenv(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := getEnvAsSlice(key, tt.defaultValue)
|
||||||
|
|
||||||
|
if len(got) != len(tt.expected) {
|
||||||
|
t.Errorf("getEnvAsSlice() length = %v, want %v", len(got), len(tt.expected))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, v := range got {
|
||||||
|
if v != tt.expected[i] {
|
||||||
|
t.Errorf("getEnvAsSlice()[%d] = %v, want %v", i, v, tt.expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanEnv removes all DYN-related env vars and returns a cleanup function
|
||||||
|
func cleanEnv() func() {
|
||||||
|
vars := []string{
|
||||||
|
"SERVER_PORT",
|
||||||
|
"DATABASE_PATH",
|
||||||
|
"TECHNITIUM_URL",
|
||||||
|
"TECHNITIUM_USERNAME",
|
||||||
|
"TECHNITIUM_PASSWORD",
|
||||||
|
"TECHNITIUM_TOKEN",
|
||||||
|
"BASE_DOMAIN",
|
||||||
|
"SPACE_SUBDOMAIN",
|
||||||
|
"RATE_LIMIT_PER_IP",
|
||||||
|
"RATE_LIMIT_PER_TOKEN",
|
||||||
|
"TRUSTED_PROXIES",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store old values
|
||||||
|
oldValues := make(map[string]string)
|
||||||
|
for _, v := range vars {
|
||||||
|
if val, ok := os.LookupEnv(v); ok {
|
||||||
|
oldValues[v] = val
|
||||||
|
os.Unsetenv(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return func() {
|
||||||
|
for _, v := range vars {
|
||||||
|
os.Unsetenv(v)
|
||||||
|
}
|
||||||
|
for v, val := range oldValues {
|
||||||
|
os.Setenv(v, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
353
internal/database/db_test.go
Normal file
353
internal/database/db_test.go
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dws.rip/DWS/dyn/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestDB(t *testing.T) (*DB, func()) {
|
||||||
|
tmpFile := "/tmp/test_dyn_" + time.Now().Format("20060102150405") + ".db"
|
||||||
|
|
||||||
|
db, err := New(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
db.Close()
|
||||||
|
os.Remove(tmpFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSpace(t *testing.T) {
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "create valid space",
|
||||||
|
subdomain: "myhome",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "create another valid space",
|
||||||
|
subdomain: "office",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate subdomain",
|
||||||
|
subdomain: "myhome",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
space, err := db.CreateSpace(ctx, tt.subdomain)
|
||||||
|
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("CreateSpace() expected error but got none")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("CreateSpace() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if space == nil {
|
||||||
|
t.Errorf("CreateSpace() returned nil space")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.Subdomain != tt.subdomain {
|
||||||
|
t.Errorf("CreateSpace() subdomain = %v, want %v", space.Subdomain, tt.subdomain)
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.Token == "" {
|
||||||
|
t.Errorf("CreateSpace() token is empty")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSpaceByToken(t *testing.T) {
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a test space
|
||||||
|
created, err := db.CreateSpace(ctx, "testspace")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test space: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "existing space",
|
||||||
|
token: created.Token,
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent token",
|
||||||
|
token: "invalid-token-12345",
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty token",
|
||||||
|
token: "",
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
space, err := db.GetSpaceByToken(ctx, tt.token)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("GetSpaceByToken() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantNil && space != nil {
|
||||||
|
t.Errorf("GetSpaceByToken() expected nil, got %v", space)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantNil && space == nil {
|
||||||
|
t.Errorf("GetSpaceByToken() expected space, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSpaceBySubdomain(t *testing.T) {
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a test space
|
||||||
|
created, err := db.CreateSpace(ctx, "mytestspace")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test space: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "existing subdomain",
|
||||||
|
subdomain: "mytestspace",
|
||||||
|
wantNil: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent subdomain",
|
||||||
|
subdomain: "nonexistent",
|
||||||
|
wantNil: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case sensitivity test",
|
||||||
|
subdomain: "MyTestSpace",
|
||||||
|
wantNil: true, // SQLite is case-sensitive by default
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
space, err := db.GetSpaceBySubdomain(ctx, tt.subdomain)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("GetSpaceBySubdomain() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.wantNil && space != nil {
|
||||||
|
t.Errorf("GetSpaceBySubdomain() expected nil, got %v", space)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantNil && space == nil {
|
||||||
|
t.Errorf("GetSpaceBySubdomain() expected space, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantNil && space.Token != created.Token {
|
||||||
|
t.Errorf("GetSpaceBySubdomain() token mismatch")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSpaceIP(t *testing.T) {
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a test space
|
||||||
|
created, err := db.CreateSpace(ctx, "updatespace")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test space: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
token string
|
||||||
|
ip string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "update valid space",
|
||||||
|
token: created.Token,
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update with different IP",
|
||||||
|
token: created.Token,
|
||||||
|
ip: "10.0.0.50",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update non-existent space",
|
||||||
|
token: "invalid-token",
|
||||||
|
ip: "192.168.1.1",
|
||||||
|
wantErr: false, // SQL UPDATE with no match doesn't error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := db.UpdateSpaceIP(ctx, tt.token, tt.ip)
|
||||||
|
|
||||||
|
if tt.wantErr && err == nil {
|
||||||
|
t.Errorf("UpdateSpaceIP() expected error but got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tt.wantErr && err != nil {
|
||||||
|
t.Errorf("UpdateSpaceIP() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the update for valid token
|
||||||
|
if !tt.wantErr && tt.token == created.Token {
|
||||||
|
space, err := db.GetSpaceByToken(ctx, tt.token)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to get space after update: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if space.LastIP != tt.ip {
|
||||||
|
t.Errorf("UpdateSpaceIP() IP = %v, want %v", space.LastIP, tt.ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubdomainExists(t *testing.T) {
|
||||||
|
db, cleanup := setupTestDB(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Create a test space
|
||||||
|
_, err := db.CreateSpace(ctx, "existingspace")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create test space: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "existing subdomain",
|
||||||
|
subdomain: "existingspace",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent subdomain",
|
||||||
|
subdomain: "newspace",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty subdomain",
|
||||||
|
subdomain: "",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
exists, err := db.SubdomainExists(ctx, tt.subdomain)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("SubdomainExists() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists != tt.want {
|
||||||
|
t.Errorf("SubdomainExists() = %v, want %v", exists, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTokenGeneration(t *testing.T) {
|
||||||
|
tokens := make(map[string]bool)
|
||||||
|
|
||||||
|
// Generate 100 tokens and ensure they're all unique
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
token, err := generateToken()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generateToken() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
t.Errorf("generateToken() returned empty string")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(token) < 16 {
|
||||||
|
t.Errorf("generateToken() returned short token: %d chars", len(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokens[token] {
|
||||||
|
t.Errorf("generateToken() returned duplicate token: %s", token)
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens[token] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSpaceModel(t *testing.T) {
|
||||||
|
space := &models.Space{
|
||||||
|
Token: "test-token",
|
||||||
|
Subdomain: "myspace",
|
||||||
|
LastIP: "192.168.1.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
fqdn := space.GetFQDN("space.dws.rip")
|
||||||
|
if fqdn != "myspace.space.dws.rip" {
|
||||||
|
t.Errorf("GetFQDN() = %v, want %v", fqdn, "myspace.space.dws.rip")
|
||||||
|
}
|
||||||
|
}
|
||||||
243
internal/handlers/handlers_test.go
Normal file
243
internal/handlers/handlers_test.go
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCustomFilter_IsBlocked(t *testing.T) {
|
||||||
|
cf := NewCustomFilter()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact match - dws",
|
||||||
|
subdomain: "dws",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match - dubey",
|
||||||
|
subdomain: "dubey",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match - tanishq",
|
||||||
|
subdomain: "tanishq",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact match - tdubey",
|
||||||
|
subdomain: "tdubey",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with hyphens - dubey-web",
|
||||||
|
subdomain: "dubey-web",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with hyphens - tanishq-dubey",
|
||||||
|
subdomain: "tanishq-dubey",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak - dub3y",
|
||||||
|
subdomain: "dub3y",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak - t4nishq",
|
||||||
|
subdomain: "t4nishq",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak - dw5",
|
||||||
|
subdomain: "dw5",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "combined term - dubeydns",
|
||||||
|
subdomain: "dubeydns",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "combined term - dws-ddns",
|
||||||
|
subdomain: "dws-ddns",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "safe subdomain - myhome",
|
||||||
|
subdomain: "myhome",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "safe subdomain - office",
|
||||||
|
subdomain: "office",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "safe subdomain - server01",
|
||||||
|
subdomain: "server01",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case insensitive - DWS",
|
||||||
|
subdomain: "DWS",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case insensitive - Tanishq",
|
||||||
|
subdomain: "Tanishq",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed case - DuBeY",
|
||||||
|
subdomain: "DuBeY",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := cf.IsBlocked(tt.subdomain)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("IsBlocked(%q) = %v, want %v", tt.subdomain, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCustomFilter_normalize(t *testing.T) {
|
||||||
|
cf := NewCustomFilter()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
text string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "lowercase conversion",
|
||||||
|
text: "DuBeY",
|
||||||
|
want: "dubey",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove hyphens",
|
||||||
|
text: "dubey-web",
|
||||||
|
want: "dubeyweb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove underscores",
|
||||||
|
text: "dubey_web",
|
||||||
|
want: "dubeyweb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove dots",
|
||||||
|
text: "dubey.web",
|
||||||
|
want: "dubeyweb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove spaces",
|
||||||
|
text: "dubey web",
|
||||||
|
want: "dubeyweb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak conversion",
|
||||||
|
text: "dub3y",
|
||||||
|
want: "dubey",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak conversion - t4nishq",
|
||||||
|
text: "t4nishq",
|
||||||
|
want: "tanishq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leet speak conversion - dw5",
|
||||||
|
text: "dw5",
|
||||||
|
want: "dws",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := cf.normalize(tt.text)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("normalize(%q) = %q, want %q", tt.text, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidSubdomain(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid - simple",
|
||||||
|
subdomain: "myhome",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid - with hyphen",
|
||||||
|
subdomain: "my-home",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid - with numbers",
|
||||||
|
subdomain: "home123",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid - minimum length",
|
||||||
|
subdomain: "abc",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - too short",
|
||||||
|
subdomain: "ab",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - too long",
|
||||||
|
subdomain: "thisisaverylongsubdomainthatexceedsthesixtythreecharacterlimitforsubdomains",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - starts with hyphen",
|
||||||
|
subdomain: "-myhome",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - ends with hyphen",
|
||||||
|
subdomain: "myhome-",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - contains special chars",
|
||||||
|
subdomain: "my_home",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - contains dot",
|
||||||
|
subdomain: "my.home",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid - empty",
|
||||||
|
subdomain: "",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := isValidSubdomain(tt.subdomain)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("isValidSubdomain(%q) = %v, want %v", tt.subdomain, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/handlers/validation.go
Normal file
45
internal/handlers/validation.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Register custom validators
|
||||||
|
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||||
|
v.RegisterValidation("alphanumdash", alphanumdashValidator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// alphanumdashValidator validates that a string contains only alphanumeric characters and hyphens
|
||||||
|
var alphanumdashValidator validator.Func = func(fl validator.FieldLevel) bool {
|
||||||
|
value := fl.Field().String()
|
||||||
|
// Allow alphanumeric and hyphens only
|
||||||
|
match := regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(value)
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidSubdomainFormat checks if a subdomain string is valid (exported for use in validation)
|
||||||
|
func IsValidSubdomainFormat(subdomain string) bool {
|
||||||
|
// Must be at least 3 characters
|
||||||
|
if len(subdomain) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not exceed 63 characters
|
||||||
|
if len(subdomain) > 63 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not start or end with hyphen
|
||||||
|
if strings.HasPrefix(subdomain, "-") || strings.HasSuffix(subdomain, "-") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must contain only alphanumeric and hyphens
|
||||||
|
return regexp.MustCompile(`^[a-zA-Z0-9-]+$`).MatchString(subdomain)
|
||||||
|
}
|
||||||
238
internal/testutil/mock_technitium.go
Normal file
238
internal/testutil/mock_technitium.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package testutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MockTechnitiumServer simulates the Technitium DNS API for testing
|
||||||
|
type MockTechnitiumServer struct {
|
||||||
|
Server *httptest.Server
|
||||||
|
Records map[string]MockDNSRecord
|
||||||
|
mu sync.RWMutex
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockDNSRecord represents a DNS record stored in the mock server
|
||||||
|
type MockDNSRecord struct {
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
IPAddress string `json:"ipAddress"`
|
||||||
|
TTL int `json:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMockTechnitiumServer creates a new mock Technitium server
|
||||||
|
func NewMockTechnitiumServer() *MockTechnitiumServer {
|
||||||
|
mock := &MockTechnitiumServer{
|
||||||
|
Records: make(map[string]MockDNSRecord),
|
||||||
|
Username: "admin",
|
||||||
|
Password: "test-password",
|
||||||
|
Token: "test-api-token",
|
||||||
|
}
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/dns/records/add", mock.handleAddRecord)
|
||||||
|
mux.HandleFunc("/api/dns/records/delete", mock.handleDeleteRecord)
|
||||||
|
mux.HandleFunc("/api/dns/records/get", mock.handleGetRecords)
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("Technitium DNS Server Mock"))
|
||||||
|
})
|
||||||
|
|
||||||
|
mock.Server = httptest.NewServer(mux)
|
||||||
|
return mock
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the mock server
|
||||||
|
func (m *MockTechnitiumServer) Close() {
|
||||||
|
m.Server.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL returns the base URL of the mock server
|
||||||
|
func (m *MockTechnitiumServer) URL() string {
|
||||||
|
return m.Server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecords returns all stored DNS records (for testing assertions)
|
||||||
|
func (m *MockTechnitiumServer) GetRecords() map[string]MockDNSRecord {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
// Return a copy to avoid race conditions
|
||||||
|
records := make(map[string]MockDNSRecord)
|
||||||
|
for k, v := range m.Records {
|
||||||
|
records[k] = v
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecordCount returns the number of stored records
|
||||||
|
func (m *MockTechnitiumServer) GetRecordCount() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return len(m.Records)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearRecords removes all stored records
|
||||||
|
func (m *MockTechnitiumServer) ClearRecords() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.Records = make(map[string]MockDNSRecord)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTechnitiumServer) authenticate(r *http.Request) bool {
|
||||||
|
// Check for API token in header
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "Basic "+m.Token {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for username/password in basic auth
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if ok && user == m.Username && pass == m.Password {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTechnitiumServer) handleAddRecord(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.authenticate(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"error": map[string]string{
|
||||||
|
"code": "Unauthorized",
|
||||||
|
"message": "Invalid credentials",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := r.FormValue("domain")
|
||||||
|
recordType := r.FormValue("type")
|
||||||
|
ipAddress := r.FormValue("ipAddress")
|
||||||
|
|
||||||
|
if domain == "" || recordType == "" {
|
||||||
|
http.Error(w, "Missing required fields", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the record
|
||||||
|
m.mu.Lock()
|
||||||
|
key := fmt.Sprintf("%s:%s", domain, recordType)
|
||||||
|
m.Records[key] = MockDNSRecord{
|
||||||
|
Domain: domain,
|
||||||
|
Type: recordType,
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
TTL: 300,
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"response": map[string]interface{}{
|
||||||
|
"domain": domain,
|
||||||
|
"type": recordType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTechnitiumServer) handleDeleteRecord(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !m.authenticate(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"error": map[string]string{
|
||||||
|
"code": "Unauthorized",
|
||||||
|
"message": "Invalid credentials",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := r.FormValue("domain")
|
||||||
|
recordType := r.FormValue("type")
|
||||||
|
|
||||||
|
// Delete the record
|
||||||
|
m.mu.Lock()
|
||||||
|
key := fmt.Sprintf("%s:%s", domain, recordType)
|
||||||
|
delete(m.Records, key)
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockTechnitiumServer) handleGetRecords(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !m.authenticate(r) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "error",
|
||||||
|
"error": map[string]string{
|
||||||
|
"code": "Unauthorized",
|
||||||
|
"message": "Invalid credentials",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := r.URL.Query().Get("domain")
|
||||||
|
|
||||||
|
m.mu.RLock()
|
||||||
|
var records []MockDNSRecord
|
||||||
|
for _, record := range m.Records {
|
||||||
|
if domain == "" || record.Domain == domain {
|
||||||
|
records = append(records, record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"response": map[string]interface{}{
|
||||||
|
"domain": domain,
|
||||||
|
"records": records,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
439
tests/integration/integration_test.go
Normal file
439
tests/integration/integration_test.go
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.dws.rip/DWS/dyn/internal/config"
|
||||||
|
"git.dws.rip/DWS/dyn/internal/database"
|
||||||
|
"git.dws.rip/DWS/dyn/internal/dns"
|
||||||
|
"git.dws.rip/DWS/dyn/internal/handlers"
|
||||||
|
"git.dws.rip/DWS/dyn/internal/models"
|
||||||
|
"git.dws.rip/DWS/dyn/internal/testutil"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupIntegrationTest(t *testing.T) (*gin.Engine, *database.DB, *testutil.MockTechnitiumServer, func()) {
|
||||||
|
// Set Gin to test mode
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
// Create temp database
|
||||||
|
tmpDB := "/tmp/test_integration_" + time.Now().Format("20060102150405") + ".db"
|
||||||
|
db, err := database.New(tmpDB)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create mock Technitium server
|
||||||
|
mockDNS := testutil.NewMockTechnitiumServer()
|
||||||
|
|
||||||
|
// Create config
|
||||||
|
cfg := &config.Config{
|
||||||
|
BaseDomain: "test.rip",
|
||||||
|
SpaceSubdomain: "space",
|
||||||
|
ServerPort: "8080",
|
||||||
|
RateLimitPerIP: 100,
|
||||||
|
RateLimitPerToken: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create DNS client pointing to mock
|
||||||
|
dnsClient := dns.NewClient(
|
||||||
|
mockDNS.URL(),
|
||||||
|
mockDNS.Token,
|
||||||
|
mockDNS.Username,
|
||||||
|
mockDNS.Password,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup Gin router
|
||||||
|
router := gin.New()
|
||||||
|
router.LoadHTMLGlob("../../web/templates/*")
|
||||||
|
|
||||||
|
webHandler := handlers.NewWebHandler(db, cfg)
|
||||||
|
dynHandler := handlers.NewDynDNSHandler(db, dnsClient, cfg)
|
||||||
|
|
||||||
|
router.GET("/", webHandler.Index)
|
||||||
|
|
||||||
|
api := router.Group("/api")
|
||||||
|
{
|
||||||
|
api.GET("/check", webHandler.CheckSubdomain)
|
||||||
|
api.POST("/claim", webHandler.ClaimSpace)
|
||||||
|
api.GET("/nic/update", dynHandler.Update)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
db.Close()
|
||||||
|
mockDNS.Close()
|
||||||
|
os.Remove(tmpDB)
|
||||||
|
}
|
||||||
|
|
||||||
|
return router, db, mockDNS, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_ClaimSpace(t *testing.T) {
|
||||||
|
router, _, _, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
wantStatus int
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful claim",
|
||||||
|
subdomain: "myhome",
|
||||||
|
wantStatus: http.StatusCreated,
|
||||||
|
wantError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate claim",
|
||||||
|
subdomain: "myhome",
|
||||||
|
wantStatus: http.StatusConflict,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "profane subdomain blocked",
|
||||||
|
subdomain: "fuck",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reserved subdomain blocked",
|
||||||
|
subdomain: "dws",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid format",
|
||||||
|
subdomain: "ab",
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(models.CreateSpaceRequest{
|
||||||
|
Subdomain: tt.subdomain,
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/claim", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantStatus, w.Code)
|
||||||
|
|
||||||
|
if !tt.wantError {
|
||||||
|
var resp models.CreateSpaceResponse
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEmpty(t, resp.Token)
|
||||||
|
assert.Equal(t, tt.subdomain, resp.Subdomain)
|
||||||
|
assert.Equal(t, tt.subdomain+".space.test.rip", resp.FQDN)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_CheckSubdomain(t *testing.T) {
|
||||||
|
router, db, _, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create a space first
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err := db.CreateSpace(ctx, "existing")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
subdomain string
|
||||||
|
wantAvailable bool
|
||||||
|
wantReason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "available subdomain",
|
||||||
|
subdomain: "newspace",
|
||||||
|
wantAvailable: true,
|
||||||
|
wantReason: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing subdomain",
|
||||||
|
subdomain: "existing",
|
||||||
|
wantAvailable: false,
|
||||||
|
wantReason: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "profane subdomain",
|
||||||
|
subdomain: "shit",
|
||||||
|
wantAvailable: false,
|
||||||
|
wantReason: "inappropriate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reserved subdomain",
|
||||||
|
subdomain: "dubey",
|
||||||
|
wantAvailable: false,
|
||||||
|
wantReason: "reserved",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/check?subdomain="+tt.subdomain, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantAvailable, resp["available"])
|
||||||
|
if tt.wantReason != "" {
|
||||||
|
assert.Equal(t, tt.wantReason, resp["reason"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_DynDNSUpdate(t *testing.T) {
|
||||||
|
router, db, mockDNS, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Create a space
|
||||||
|
ctx := context.Background()
|
||||||
|
space, err := db.CreateSpace(ctx, "myrouter")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create basic auth header
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte("none:" + space.Token))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
hostname string
|
||||||
|
myip string
|
||||||
|
authHeader string
|
||||||
|
wantStatus int
|
||||||
|
wantBody string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful update with IP",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "192.168.1.100",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: "good 192.168.1.100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "same IP - no change",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "192.168.1.100",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: "nochg 192.168.1.100",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different IP",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "10.0.0.50",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusOK,
|
||||||
|
wantBody: "good 10.0.0.50",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing auth",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "192.168.1.1",
|
||||||
|
authHeader: "",
|
||||||
|
wantStatus: http.StatusUnauthorized,
|
||||||
|
wantBody: "badauth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid token",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "192.168.1.1",
|
||||||
|
authHeader: "Basic " + base64.StdEncoding.EncodeToString([]byte("none:invalid-token")),
|
||||||
|
wantStatus: http.StatusUnauthorized,
|
||||||
|
wantBody: "badauth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong hostname",
|
||||||
|
hostname: "wrong.space.test.rip",
|
||||||
|
myip: "192.168.1.1",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "nohost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing hostname",
|
||||||
|
hostname: "",
|
||||||
|
myip: "192.168.1.1",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "nohost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid IP",
|
||||||
|
hostname: "myrouter.space.test.rip",
|
||||||
|
myip: "not-an-ip",
|
||||||
|
authHeader: "Basic " + auth,
|
||||||
|
wantStatus: http.StatusBadRequest,
|
||||||
|
wantBody: "dnserr",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
url := "/api/nic/update?hostname=" + tt.hostname
|
||||||
|
if tt.myip != "" {
|
||||||
|
url += "&myip=" + tt.myip
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if tt.authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", tt.authHeader)
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantStatus, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), tt.wantBody)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DNS records were created
|
||||||
|
records := mockDNS.GetRecords()
|
||||||
|
assert.Len(t, records, 2) // A record + wildcard record
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_FullWorkflow(t *testing.T) {
|
||||||
|
router, _, mockDNS, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Step 1: Check if subdomain is available
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/check?subdomain=myhome", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
var checkResp map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &checkResp)
|
||||||
|
assert.True(t, checkResp["available"].(bool))
|
||||||
|
|
||||||
|
// Step 2: Claim the space
|
||||||
|
claimBody, _ := json.Marshal(models.CreateSpaceRequest{Subdomain: "myhome"})
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/claim", bytes.NewBuffer(claimBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusCreated, w.Code)
|
||||||
|
var claimResp models.CreateSpaceResponse
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &claimResp)
|
||||||
|
token := claimResp.Token
|
||||||
|
require.NotEmpty(t, token)
|
||||||
|
|
||||||
|
// Step 3: Update DNS via DynDNS protocol
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte("none:" + token))
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/api/nic/update?hostname=myhome.space.test.rip&myip=1.2.3.4", nil)
|
||||||
|
req.Header.Set("Authorization", "Basic "+auth)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "good 1.2.3.4")
|
||||||
|
|
||||||
|
// Step 4: Verify DNS records were created in mock server
|
||||||
|
records := mockDNS.GetRecords()
|
||||||
|
assert.Len(t, records, 2)
|
||||||
|
|
||||||
|
// Check for A record
|
||||||
|
aRecord, exists := records["myhome.space.test.rip:A"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.Equal(t, "1.2.3.4", aRecord.IPAddress)
|
||||||
|
|
||||||
|
// Check for wildcard record
|
||||||
|
wildcardRecord, exists := records["*.myhome.space.test.rip:A"]
|
||||||
|
assert.True(t, exists)
|
||||||
|
assert.Equal(t, "1.2.3.4", wildcardRecord.IPAddress)
|
||||||
|
|
||||||
|
// Step 5: Try to claim same subdomain (should fail)
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/claim", bytes.NewBuffer(claimBody))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_ProfanityFiltering(t *testing.T) {
|
||||||
|
router, _, _, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
profaneSubdomains := []string{
|
||||||
|
"fuck",
|
||||||
|
"shit",
|
||||||
|
"ass",
|
||||||
|
"bitch",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subdomain := range profaneSubdomains {
|
||||||
|
t.Run(subdomain, func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(models.CreateSpaceRequest{Subdomain: subdomain})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/claim", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
var resp map[string]string
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
assert.Contains(t, resp["error"], "inappropriate")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegration_CustomFilter(t *testing.T) {
|
||||||
|
router, _, _, cleanup := setupIntegrationTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
reservedSubdomains := []string{
|
||||||
|
"dws",
|
||||||
|
"dubey",
|
||||||
|
"tanishq",
|
||||||
|
"tdubey",
|
||||||
|
"dub3y",
|
||||||
|
"t4nishq",
|
||||||
|
"dwsengineering",
|
||||||
|
"dubeydns",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, subdomain := range reservedSubdomains {
|
||||||
|
t.Run(subdomain, func(t *testing.T) {
|
||||||
|
body, _ := json.Marshal(models.CreateSpaceRequest{Subdomain: subdomain})
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/claim", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
var resp map[string]string
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||||
|
assert.Contains(t, resp["error"], "reserved")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user