diff --git a/go.mod b/go.mod index 1caea01..b757166 100644 --- a/go.mod +++ b/go.mod @@ -3,15 +3,17 @@ module git.dws.rip/DWS/dyn go 1.24.4 require ( + github.com/TwiN/go-away v1.8.1 github.com/gin-gonic/gin v1.11.0 github.com/mattn/go-sqlite3 v1.14.24 + github.com/stretchr/testify v1.11.1 ) require ( - github.com/TwiN/go-away v1.8.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // 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/gin-contrib/sse v1.1.0 // 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/reflect2 v1.0.2 // 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/quic-go v0.54.0 // 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/tools v0.37.0 // indirect google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ca7623a..69e69f8 100644 --- a/go.sum +++ b/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= 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/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/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/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/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/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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/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/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/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= 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/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..9938e3c --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } + } +} diff --git a/internal/database/db_test.go b/internal/database/db_test.go new file mode 100644 index 0000000..b647722 --- /dev/null +++ b/internal/database/db_test.go @@ -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") + } +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go new file mode 100644 index 0000000..5b99dcd --- /dev/null +++ b/internal/handlers/handlers_test.go @@ -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) + } + }) + } +} diff --git a/internal/handlers/validation.go b/internal/handlers/validation.go new file mode 100644 index 0000000..e2bbb49 --- /dev/null +++ b/internal/handlers/validation.go @@ -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) +} diff --git a/internal/testutil/mock_technitium.go b/internal/testutil/mock_technitium.go new file mode 100644 index 0000000..b95d3b1 --- /dev/null +++ b/internal/testutil/mock_technitium.go @@ -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, + }, + }) +} diff --git a/tests/integration/integration_test.go b/tests/integration/integration_test.go new file mode 100644 index 0000000..ad9f046 --- /dev/null +++ b/tests/integration/integration_test.go @@ -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") + }) + } +}