chore: git cache cleanup
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccessList defines IP-based or auth-based access control rules
|
||||
// that can be applied to proxy hosts.
|
||||
type AccessList struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type" gorm:"index"` // "whitelist", "blacklist", "geo_whitelist", "geo_blacklist"
|
||||
IPRules string `json:"ip_rules" gorm:"type:text"` // JSON array of IP/CIDR rules
|
||||
CountryCodes string `json:"country_codes"` // Comma-separated ISO country codes (for geo types)
|
||||
LocalNetworkOnly bool `json:"local_network_only"` // RFC1918 private networks only
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// AccessListRule represents a single IP or CIDR rule
|
||||
type AccessListRule struct {
|
||||
CIDR string `json:"cidr"` // IP address or CIDR notation
|
||||
Description string `json:"description"` // Optional description
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CaddyConfig stores an audit trail of Caddy configuration changes.
|
||||
type CaddyConfig struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
ConfigHash string `json:"-" gorm:"index"`
|
||||
AppliedAt time.Time `json:"applied_at"`
|
||||
Success bool `json:"success"`
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CrowdsecConsoleEnrollment stores enrollment status and secrets for console registration.
|
||||
type CrowdsecConsoleEnrollment struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Status string `json:"status" gorm:"index"`
|
||||
Tenant string `json:"tenant"`
|
||||
AgentName string `json:"agent_name"`
|
||||
EncryptedEnrollKey string `json:"-" gorm:"type:text"`
|
||||
LastError string `json:"last_error" gorm:"type:text"`
|
||||
LastCorrelationID string `json:"last_correlation_id" gorm:"index"`
|
||||
LastAttemptAt *time.Time `json:"last_attempt_at"`
|
||||
EnrolledAt *time.Time `json:"enrolled_at"`
|
||||
LastHeartbeatAt *time.Time `json:"last_heartbeat_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CrowdsecPresetEvent captures audit trail for preset pull/apply events.
|
||||
type CrowdsecPresetEvent struct {
|
||||
ID uint `gorm:"primarykey" json:"-"`
|
||||
Slug string `json:"slug"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
BackupPath string `json:"backup_path"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Package models defines the database schema and domain types.
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DNSProvider represents a DNS provider configuration for ACME DNS-01 challenges.
|
||||
// Credentials are stored encrypted at rest using AES-256-GCM.
|
||||
type DNSProvider struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;size:36"`
|
||||
Name string `json:"name" gorm:"index;not null;size:255"`
|
||||
ProviderType string `json:"provider_type" gorm:"index;not null;size:50"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true;index"`
|
||||
IsDefault bool `json:"is_default" gorm:"default:false"`
|
||||
|
||||
// Multi-credential mode (enables zone-specific credentials)
|
||||
UseMultiCredentials bool `json:"use_multi_credentials" gorm:"default:false"`
|
||||
|
||||
// Relationship to zone-specific credentials
|
||||
Credentials []DNSProviderCredential `json:"credentials,omitempty" gorm:"foreignKey:DNSProviderID"`
|
||||
|
||||
// Encrypted credentials (JSON blob, encrypted with AES-256-GCM)
|
||||
// Kept for backward compatibility when UseMultiCredentials=false
|
||||
CredentialsEncrypted string `json:"-" gorm:"type:text;column:credentials_encrypted"`
|
||||
|
||||
// Encryption key version used for credentials (supports key rotation)
|
||||
KeyVersion int `json:"key_version" gorm:"default:1;index"`
|
||||
|
||||
// Propagation settings
|
||||
PropagationTimeout int `json:"propagation_timeout" gorm:"default:120"` // seconds
|
||||
PollingInterval int `json:"polling_interval" gorm:"default:5"` // seconds
|
||||
|
||||
// Usage tracking
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
SuccessCount int `json:"success_count" gorm:"default:0"`
|
||||
FailureCount int `json:"failure_count" gorm:"default:0"`
|
||||
LastError string `json:"last_error,omitempty" gorm:"type:text"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the database table name.
|
||||
func (DNSProvider) TableName() string {
|
||||
return "dns_providers"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Package models defines the database schema and domain types.
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// DNSProviderCredential represents a zone-specific credential set for a DNS provider.
|
||||
// This allows different credentials to be used for different domains/zones within the same provider.
|
||||
type DNSProviderCredential struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;size:36"`
|
||||
DNSProviderID uint `json:"dns_provider_id" gorm:"index;not null"`
|
||||
DNSProvider *DNSProvider `json:"dns_provider,omitempty" gorm:"foreignKey:DNSProviderID"`
|
||||
|
||||
// Credential metadata
|
||||
Label string `json:"label" gorm:"not null;size:255"`
|
||||
ZoneFilter string `json:"zone_filter" gorm:"type:text"` // Comma-separated list of domains (e.g., "example.com,*.example.org")
|
||||
Enabled bool `json:"enabled" gorm:"default:true;index"`
|
||||
|
||||
// Encrypted credentials (JSON blob, encrypted with AES-256-GCM)
|
||||
CredentialsEncrypted string `json:"-" gorm:"type:text;not null"`
|
||||
|
||||
// Encryption key version used for credentials (supports key rotation)
|
||||
KeyVersion int `json:"key_version" gorm:"default:1;index"`
|
||||
|
||||
// Propagation settings (overrides provider defaults if non-zero)
|
||||
PropagationTimeout int `json:"propagation_timeout" gorm:"default:120"` // seconds
|
||||
PollingInterval int `json:"polling_interval" gorm:"default:5"` // seconds
|
||||
|
||||
// Usage tracking
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
SuccessCount int `json:"success_count" gorm:"default:0"`
|
||||
FailureCount int `json:"failure_count" gorm:"default:0"`
|
||||
LastError string `json:"last_error,omitempty" gorm:"type:text"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the database table name.
|
||||
func (DNSProviderCredential) TableName() string {
|
||||
return "dns_provider_credentials"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDNSProviderCredential_TableName(t *testing.T) {
|
||||
cred := &models.DNSProviderCredential{}
|
||||
assert.Equal(t, "dns_provider_credentials", cred.TableName())
|
||||
}
|
||||
|
||||
func TestDNSProviderCredential_Struct(t *testing.T) {
|
||||
now := time.Now()
|
||||
cred := &models.DNSProviderCredential{
|
||||
ID: 1,
|
||||
UUID: "test-uuid",
|
||||
DNSProviderID: 1,
|
||||
Label: "Test Credential",
|
||||
ZoneFilter: "example.com,*.example.org",
|
||||
CredentialsEncrypted: "encrypted_data",
|
||||
Enabled: true,
|
||||
KeyVersion: 1,
|
||||
PropagationTimeout: 120,
|
||||
PollingInterval: 5,
|
||||
SuccessCount: 10,
|
||||
FailureCount: 2,
|
||||
LastError: "",
|
||||
LastUsedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
assert.Equal(t, uint(1), cred.ID)
|
||||
assert.Equal(t, "test-uuid", cred.UUID)
|
||||
assert.Equal(t, uint(1), cred.DNSProviderID)
|
||||
assert.Equal(t, "Test Credential", cred.Label)
|
||||
assert.Equal(t, "example.com,*.example.org", cred.ZoneFilter)
|
||||
assert.Equal(t, "encrypted_data", cred.CredentialsEncrypted)
|
||||
assert.True(t, cred.Enabled)
|
||||
assert.Equal(t, 1, cred.KeyVersion)
|
||||
assert.Equal(t, 120, cred.PropagationTimeout)
|
||||
assert.Equal(t, 5, cred.PollingInterval)
|
||||
assert.Equal(t, 10, cred.SuccessCount)
|
||||
assert.Equal(t, 2, cred.FailureCount)
|
||||
assert.Equal(t, "", cred.LastError)
|
||||
assert.NotNil(t, cred.LastUsedAt)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDNSProvider_TableName(t *testing.T) {
|
||||
provider := DNSProvider{}
|
||||
assert.Equal(t, "dns_providers", provider.TableName())
|
||||
}
|
||||
|
||||
func TestDNSProvider_Fields(t *testing.T) {
|
||||
provider := DNSProvider{
|
||||
UUID: "test-uuid",
|
||||
Name: "Test Provider",
|
||||
ProviderType: "cloudflare",
|
||||
Enabled: true,
|
||||
IsDefault: false,
|
||||
PropagationTimeout: 120,
|
||||
PollingInterval: 5,
|
||||
SuccessCount: 0,
|
||||
FailureCount: 0,
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-uuid", provider.UUID)
|
||||
assert.Equal(t, "Test Provider", provider.Name)
|
||||
assert.Equal(t, "cloudflare", provider.ProviderType)
|
||||
assert.True(t, provider.Enabled)
|
||||
assert.False(t, provider.IsDefault)
|
||||
assert.Equal(t, 120, provider.PropagationTimeout)
|
||||
assert.Equal(t, 5, provider.PollingInterval)
|
||||
assert.Equal(t, 0, provider.SuccessCount)
|
||||
assert.Equal(t, 0, provider.FailureCount)
|
||||
}
|
||||
|
||||
func TestDNSProvider_CredentialsEncrypted_NotSerialized(t *testing.T) {
|
||||
// This test verifies that the CredentialsEncrypted field has the json:"-" tag
|
||||
// by checking that it's not included in JSON serialization
|
||||
provider := DNSProvider{
|
||||
Name: "Test",
|
||||
ProviderType: "cloudflare",
|
||||
CredentialsEncrypted: "encrypted-data-should-not-appear-in-json",
|
||||
}
|
||||
|
||||
// Marshal to JSON
|
||||
jsonData, err := json.Marshal(provider)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify credentials are not in JSON
|
||||
jsonString := string(jsonData)
|
||||
assert.NotContains(t, jsonString, "credentials_encrypted")
|
||||
assert.NotContains(t, jsonString, "encrypted-data-should-not-appear-in-json")
|
||||
assert.Contains(t, jsonString, "Test")
|
||||
assert.Contains(t, jsonString, "cloudflare")
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Domain struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"`
|
||||
}
|
||||
|
||||
func (d *Domain) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if d.UUID == "" {
|
||||
d.UUID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestDomain_BeforeCreate(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
_ = db.AutoMigrate(&Domain{})
|
||||
|
||||
// Case 1: UUID is empty, should be generated
|
||||
d1 := &Domain{Name: "example.com"}
|
||||
err = db.Create(d1).Error
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, d1.UUID)
|
||||
|
||||
// Case 2: UUID is provided, should be kept
|
||||
uuid := "123e4567-e89b-12d3-a456-426614174000"
|
||||
d2 := &Domain{Name: "test.com", UUID: uuid}
|
||||
err = db.Create(d2).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uuid, d2.UUID)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// EmergencyToken stores metadata for database-backed emergency access tokens.
|
||||
// Tokens are stored as bcrypt hashes for security.
|
||||
type EmergencyToken struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
TokenHash string `json:"-" gorm:"type:text;not null"` // bcrypt hash, never exposed in JSON
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt *time.Time `json:"expires_at"` // NULL = never expires
|
||||
ExpirationPolicy string `json:"expiration_policy" gorm:"type:text;not null"` // "30_days", "60_days", "90_days", "custom", "never"
|
||||
CreatedByUserID *uint `json:"created_by_user_id"` // User who generated token (NULL for env var tokens)
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
UseCount int `json:"use_count" gorm:"default:0"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for GORM
|
||||
func (EmergencyToken) TableName() string {
|
||||
return "emergency_tokens"
|
||||
}
|
||||
|
||||
// IsExpired checks if the token has expired
|
||||
func (et *EmergencyToken) IsExpired() bool {
|
||||
if et.ExpiresAt == nil {
|
||||
return false // Never expires
|
||||
}
|
||||
return time.Now().After(*et.ExpiresAt)
|
||||
}
|
||||
|
||||
// DaysUntilExpiration returns the number of days until expiration (negative if expired)
|
||||
func (et *EmergencyToken) DaysUntilExpiration() int {
|
||||
if et.ExpiresAt == nil {
|
||||
return -1 // Special value for "never expires"
|
||||
}
|
||||
duration := time.Until(*et.ExpiresAt)
|
||||
return int(duration.Hours() / 24)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestEmergencyToken_TableName(t *testing.T) {
|
||||
token := EmergencyToken{}
|
||||
assert.Equal(t, "emergency_tokens", token.TableName())
|
||||
}
|
||||
|
||||
func TestEmergencyToken_IsExpired(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt *time.Time
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil expiration (never expires)",
|
||||
expiresAt: nil,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired token (1 hour ago)",
|
||||
expiresAt: ptrTime(now.Add(-1 * time.Hour)),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "expired token (1 day ago)",
|
||||
expiresAt: ptrTime(now.Add(-24 * time.Hour)),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "valid token (1 hour from now)",
|
||||
expiresAt: ptrTime(now.Add(1 * time.Hour)),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "valid token (30 days from now)",
|
||||
expiresAt: ptrTime(now.Add(30 * 24 * time.Hour)),
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired by 1 second",
|
||||
expiresAt: ptrTime(now.Add(-1 * time.Second)),
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token := &EmergencyToken{
|
||||
ExpiresAt: tt.expiresAt,
|
||||
}
|
||||
result := token.IsExpired()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmergencyToken_DaysUntilExpiration(t *testing.T) {
|
||||
// Test with actual time.Now() since the method uses it internally
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expires *time.Time
|
||||
minDays int
|
||||
maxDays int
|
||||
}{
|
||||
{
|
||||
name: "nil expiration",
|
||||
expires: nil,
|
||||
minDays: -1,
|
||||
maxDays: -1,
|
||||
},
|
||||
{
|
||||
name: "expires in ~1 day",
|
||||
expires: ptrTime(now.Add(24 * time.Hour)),
|
||||
minDays: 0,
|
||||
maxDays: 1,
|
||||
},
|
||||
{
|
||||
name: "expires in ~30 days",
|
||||
expires: ptrTime(now.Add(30 * 24 * time.Hour)),
|
||||
minDays: 29,
|
||||
maxDays: 30,
|
||||
},
|
||||
{
|
||||
name: "expires in ~60 days",
|
||||
expires: ptrTime(now.Add(60 * 24 * time.Hour)),
|
||||
minDays: 59,
|
||||
maxDays: 60,
|
||||
},
|
||||
{
|
||||
name: "expires in ~90 days",
|
||||
expires: ptrTime(now.Add(90 * 24 * time.Hour)),
|
||||
minDays: 89,
|
||||
maxDays: 90,
|
||||
},
|
||||
{
|
||||
name: "expired ~1 day ago",
|
||||
expires: ptrTime(now.Add(-24 * time.Hour)),
|
||||
minDays: -2,
|
||||
maxDays: -1,
|
||||
},
|
||||
{
|
||||
name: "expired ~10 days ago",
|
||||
expires: ptrTime(now.Add(-10 * 24 * time.Hour)),
|
||||
minDays: -11,
|
||||
maxDays: -10,
|
||||
},
|
||||
{
|
||||
name: "expires in ~12 hours (partial day)",
|
||||
expires: ptrTime(now.Add(12 * time.Hour)),
|
||||
minDays: 0,
|
||||
maxDays: 1,
|
||||
},
|
||||
{
|
||||
name: "expires in ~36 hours (1.5 days)",
|
||||
expires: ptrTime(now.Add(36 * time.Hour)),
|
||||
minDays: 1,
|
||||
maxDays: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token := &EmergencyToken{ExpiresAt: tt.expires}
|
||||
result := token.DaysUntilExpiration()
|
||||
|
||||
assert.GreaterOrEqual(t, result, tt.minDays, "days should be >= min")
|
||||
assert.LessOrEqual(t, result, tt.maxDays, "days should be <= max")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ptrTime is a helper to create a pointer to a time.Time
|
||||
func ptrTime(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&NotificationTemplate{}, &UptimeHost{}, &UptimeNotificationEvent{}); err != nil {
|
||||
t.Fatalf("auto migrate failed: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNotificationTemplate_BeforeCreate(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
tmpl := &NotificationTemplate{
|
||||
Name: "hook-test",
|
||||
}
|
||||
if err := db.Create(tmpl).Error; err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if tmpl.ID == "" {
|
||||
t.Fatalf("expected ID to be populated by BeforeCreate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUptimeHost_BeforeCreate(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
h := &UptimeHost{
|
||||
Host: "127.0.0.1",
|
||||
}
|
||||
if err := db.Create(h).Error; err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if h.ID == "" {
|
||||
t.Fatalf("expected ID to be populated by BeforeCreate")
|
||||
}
|
||||
if h.Status != "pending" {
|
||||
t.Fatalf("expected default Status 'pending', got %q", h.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUptimeNotificationEvent_BeforeCreate(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
e := &UptimeNotificationEvent{
|
||||
HostID: "host-1",
|
||||
EventType: "down",
|
||||
}
|
||||
if err := db.Create(e).Error; err != nil {
|
||||
t.Fatalf("create failed: %v", err)
|
||||
}
|
||||
if e.ID == "" {
|
||||
t.Fatalf("expected ID to be populated by BeforeCreate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ImportSession tracks Caddyfile import operations with pending state
|
||||
// until user reviews and confirms via UI.
|
||||
type ImportSession struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
SourceFile string `json:"source_file"` // Path to original Caddyfile
|
||||
Status string `json:"status" gorm:"default:'pending'"` // "pending", "reviewing", "committed", "rejected", "failed"
|
||||
ParsedData string `json:"parsed_data" gorm:"type:text"` // JSON representation of detected hosts
|
||||
ConflictReport string `json:"conflict_report" gorm:"type:text"` // JSON array of conflicts
|
||||
UserResolutions string `json:"user_resolutions" gorm:"type:text"` // JSON map of conflict resolutions
|
||||
ErrorMsg string `json:"error_msg"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CommittedAt *time.Time `json:"committed_at,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Location represents a custom path-based proxy configuration within a ProxyHost.
|
||||
type Location struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
|
||||
ProxyHostID uint `json:"proxy_host_id" gorm:"not null;index"`
|
||||
Path string `json:"path" gorm:"not null;index"` // e.g., /api, /admin
|
||||
ForwardScheme string `json:"forward_scheme" gorm:"default:http"`
|
||||
ForwardHost string `json:"forward_host" gorm:"not null;index"`
|
||||
ForwardPort int `json:"forward_port" gorm:"not null"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package models
|
||||
|
||||
// CaddyAccessLog represents a structured log entry from Caddy's JSON access logs.
|
||||
type CaddyAccessLog struct {
|
||||
Level string `json:"level"`
|
||||
Ts float64 `json:"ts"`
|
||||
Logger string `json:"logger"`
|
||||
Msg string `json:"msg"`
|
||||
Request struct {
|
||||
RemoteIP string `json:"remote_ip"`
|
||||
RemotePort string `json:"remote_port"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Proto string `json:"proto"`
|
||||
Method string `json:"method"`
|
||||
Host string `json:"host"`
|
||||
URI string `json:"uri"`
|
||||
Headers map[string][]string `json:"headers"`
|
||||
TLS struct {
|
||||
Resumed bool `json:"resumed"`
|
||||
Version int `json:"version"`
|
||||
CipherSuite int `json:"cipher_suite"`
|
||||
Proto string `json:"proto"`
|
||||
ServerName string `json:"server_name"`
|
||||
} `json:"tls"`
|
||||
} `json:"request"`
|
||||
BytesRead int `json:"bytes_read"`
|
||||
UserID string `json:"user_id"`
|
||||
Duration float64 `json:"duration"`
|
||||
Size int `json:"size"`
|
||||
Status int `json:"status"`
|
||||
RespHeaders map[string][]string `json:"resp_headers"`
|
||||
}
|
||||
|
||||
// LogFilter defines criteria for filtering logs.
|
||||
type LogFilter struct {
|
||||
Search string `form:"search"`
|
||||
Host string `form:"host"`
|
||||
Status string `form:"status"` // e.g., "200", "4xx", "5xx"
|
||||
Level string `form:"level"`
|
||||
Limit int `form:"limit"`
|
||||
Offset int `form:"offset"`
|
||||
Sort string `form:"sort"`
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package models defines the database schema and domain types.
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ChallengeStatus represents the state of a manual DNS challenge.
|
||||
type ChallengeStatus string
|
||||
|
||||
const (
|
||||
// ChallengeStatusCreated indicates the challenge has been created but not yet processed.
|
||||
ChallengeStatusCreated ChallengeStatus = "created"
|
||||
// ChallengeStatusPending indicates the challenge is waiting for DNS propagation.
|
||||
ChallengeStatusPending ChallengeStatus = "pending"
|
||||
// ChallengeStatusVerifying indicates DNS record was found, verification in progress.
|
||||
ChallengeStatusVerifying ChallengeStatus = "verifying"
|
||||
// ChallengeStatusVerified indicates the challenge was successfully verified.
|
||||
ChallengeStatusVerified ChallengeStatus = "verified"
|
||||
// ChallengeStatusExpired indicates the challenge timed out.
|
||||
ChallengeStatusExpired ChallengeStatus = "expired"
|
||||
// ChallengeStatusFailed indicates the challenge failed.
|
||||
ChallengeStatusFailed ChallengeStatus = "failed"
|
||||
)
|
||||
|
||||
// ManualChallenge represents a manual DNS challenge for ACME DNS-01 validation.
|
||||
// Users manually create the required TXT record at their DNS provider.
|
||||
type ManualChallenge struct {
|
||||
// ID is the primary key (UUIDv4, cryptographically random).
|
||||
ID string `json:"id" gorm:"primaryKey;size:36"`
|
||||
|
||||
// ProviderID is the foreign key to the DNS provider.
|
||||
ProviderID uint `json:"provider_id" gorm:"index;not null"`
|
||||
|
||||
// UserID is the foreign key for ownership validation.
|
||||
UserID uint `json:"user_id" gorm:"index;not null"`
|
||||
|
||||
// FQDN is the fully qualified domain name for the TXT record.
|
||||
// Example: "_acme-challenge.example.com"
|
||||
FQDN string `json:"fqdn" gorm:"index;not null;size:255"`
|
||||
|
||||
// Token is the ACME challenge token (for identification), never exposed in JSON.
|
||||
Token string `json:"-" gorm:"size:255"`
|
||||
|
||||
// Value is the TXT record value that must be created.
|
||||
Value string `json:"value" gorm:"not null;size:255"`
|
||||
|
||||
// Status is the current state of the challenge.
|
||||
Status ChallengeStatus `json:"status" gorm:"index;not null;size:20;default:'created'"`
|
||||
|
||||
// ErrorMessage stores any error message if the challenge failed.
|
||||
ErrorMessage string `json:"error_message,omitempty" gorm:"type:text"`
|
||||
|
||||
// DNSPropagated indicates if the DNS record has been detected.
|
||||
DNSPropagated bool `json:"dns_propagated" gorm:"default:false"`
|
||||
|
||||
// CreatedAt is when the challenge was created.
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// ExpiresAt is when the challenge will expire.
|
||||
ExpiresAt time.Time `json:"expires_at" gorm:"index"`
|
||||
|
||||
// LastCheckAt is when DNS was last checked for propagation.
|
||||
LastCheckAt *time.Time `json:"last_check_at,omitempty"`
|
||||
|
||||
// VerifiedAt is when the challenge was successfully verified.
|
||||
VerifiedAt *time.Time `json:"verified_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName specifies the database table name.
|
||||
func (ManualChallenge) TableName() string {
|
||||
return "manual_challenges"
|
||||
}
|
||||
|
||||
// IsTerminal returns true if the challenge is in a terminal state.
|
||||
func (c *ManualChallenge) IsTerminal() bool {
|
||||
return c.Status == ChallengeStatusVerified ||
|
||||
c.Status == ChallengeStatusExpired ||
|
||||
c.Status == ChallengeStatusFailed
|
||||
}
|
||||
|
||||
// IsActive returns true if the challenge is in an active state.
|
||||
func (c *ManualChallenge) IsActive() bool {
|
||||
return c.Status == ChallengeStatusCreated ||
|
||||
c.Status == ChallengeStatusPending ||
|
||||
c.Status == ChallengeStatusVerifying
|
||||
}
|
||||
|
||||
// TimeRemaining returns the duration until the challenge expires.
|
||||
func (c *ManualChallenge) TimeRemaining() time.Duration {
|
||||
remaining := time.Until(c.ExpiresAt)
|
||||
if remaining < 0 {
|
||||
return 0
|
||||
}
|
||||
return remaining
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestChallengeStatus_Constants(t *testing.T) {
|
||||
// Verify all expected status values exist
|
||||
assert.Equal(t, ChallengeStatus("created"), ChallengeStatusCreated)
|
||||
assert.Equal(t, ChallengeStatus("pending"), ChallengeStatusPending)
|
||||
assert.Equal(t, ChallengeStatus("verifying"), ChallengeStatusVerifying)
|
||||
assert.Equal(t, ChallengeStatus("verified"), ChallengeStatusVerified)
|
||||
assert.Equal(t, ChallengeStatus("expired"), ChallengeStatusExpired)
|
||||
assert.Equal(t, ChallengeStatus("failed"), ChallengeStatusFailed)
|
||||
}
|
||||
|
||||
func TestManualChallenge_TableName(t *testing.T) {
|
||||
challenge := ManualChallenge{}
|
||||
assert.Equal(t, "manual_challenges", challenge.TableName())
|
||||
}
|
||||
|
||||
func TestManualChallenge_IsTerminal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status ChallengeStatus
|
||||
expected bool
|
||||
}{
|
||||
{"created is not terminal", ChallengeStatusCreated, false},
|
||||
{"pending is not terminal", ChallengeStatusPending, false},
|
||||
{"verifying is not terminal", ChallengeStatusVerifying, false},
|
||||
{"verified is terminal", ChallengeStatusVerified, true},
|
||||
{"expired is terminal", ChallengeStatusExpired, true},
|
||||
{"failed is terminal", ChallengeStatusFailed, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
challenge := &ManualChallenge{Status: tt.status}
|
||||
assert.Equal(t, tt.expected, challenge.IsTerminal())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualChallenge_IsActive(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
status ChallengeStatus
|
||||
expected bool
|
||||
}{
|
||||
{"created is active", ChallengeStatusCreated, true},
|
||||
{"pending is active", ChallengeStatusPending, true},
|
||||
{"verifying is active", ChallengeStatusVerifying, true},
|
||||
{"verified is not active", ChallengeStatusVerified, false},
|
||||
{"expired is not active", ChallengeStatusExpired, false},
|
||||
{"failed is not active", ChallengeStatusFailed, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
challenge := &ManualChallenge{Status: tt.status}
|
||||
assert.Equal(t, tt.expected, challenge.IsActive())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualChallenge_TimeRemaining(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
expectPositive bool
|
||||
}{
|
||||
{
|
||||
name: "future expiration returns positive duration",
|
||||
expiresAt: time.Now().Add(10 * time.Minute),
|
||||
expectPositive: true,
|
||||
},
|
||||
{
|
||||
name: "past expiration returns zero",
|
||||
expiresAt: time.Now().Add(-5 * time.Minute),
|
||||
expectPositive: false,
|
||||
},
|
||||
{
|
||||
name: "just expired returns zero",
|
||||
expiresAt: time.Now().Add(-1 * time.Second),
|
||||
expectPositive: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
challenge := &ManualChallenge{ExpiresAt: tt.expiresAt}
|
||||
remaining := challenge.TimeRemaining()
|
||||
|
||||
if tt.expectPositive {
|
||||
assert.Greater(t, remaining, time.Duration(0))
|
||||
} else {
|
||||
assert.Equal(t, time.Duration(0), remaining)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualChallenge_StructFields(t *testing.T) {
|
||||
now := time.Now()
|
||||
lastCheck := now.Add(-1 * time.Minute)
|
||||
verified := now.Add(-30 * time.Second)
|
||||
|
||||
challenge := &ManualChallenge{
|
||||
ID: "test-uuid-123",
|
||||
ProviderID: 1,
|
||||
UserID: 2,
|
||||
FQDN: "_acme-challenge.example.com",
|
||||
Token: "token123",
|
||||
Value: "txtvalue456",
|
||||
Status: ChallengeStatusPending,
|
||||
ErrorMessage: "",
|
||||
DNSPropagated: false,
|
||||
CreatedAt: now,
|
||||
ExpiresAt: now.Add(10 * time.Minute),
|
||||
LastCheckAt: &lastCheck,
|
||||
VerifiedAt: &verified,
|
||||
}
|
||||
|
||||
assert.Equal(t, "test-uuid-123", challenge.ID)
|
||||
assert.Equal(t, uint(1), challenge.ProviderID)
|
||||
assert.Equal(t, uint(2), challenge.UserID)
|
||||
assert.Equal(t, "_acme-challenge.example.com", challenge.FQDN)
|
||||
assert.Equal(t, "token123", challenge.Token)
|
||||
assert.Equal(t, "txtvalue456", challenge.Value)
|
||||
assert.Equal(t, ChallengeStatusPending, challenge.Status)
|
||||
assert.Empty(t, challenge.ErrorMessage)
|
||||
assert.False(t, challenge.DNSPropagated)
|
||||
assert.Equal(t, now, challenge.CreatedAt)
|
||||
assert.NotNil(t, challenge.LastCheckAt)
|
||||
assert.NotNil(t, challenge.VerifiedAt)
|
||||
}
|
||||
|
||||
func TestManualChallenge_NilOptionalFields(t *testing.T) {
|
||||
challenge := &ManualChallenge{
|
||||
ID: "test-uuid",
|
||||
ProviderID: 1,
|
||||
UserID: 1,
|
||||
FQDN: "_acme-challenge.example.com",
|
||||
Value: "value",
|
||||
Status: ChallengeStatusCreated,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
LastCheckAt: nil,
|
||||
VerifiedAt: nil,
|
||||
}
|
||||
|
||||
assert.Nil(t, challenge.LastCheckAt)
|
||||
assert.Nil(t, challenge.VerifiedAt)
|
||||
assert.True(t, challenge.IsActive())
|
||||
assert.False(t, challenge.IsTerminal())
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NotificationType string
|
||||
|
||||
const (
|
||||
NotificationTypeInfo NotificationType = "info"
|
||||
NotificationTypeSuccess NotificationType = "success"
|
||||
NotificationTypeWarning NotificationType = "warning"
|
||||
NotificationTypeError NotificationType = "error"
|
||||
)
|
||||
|
||||
type Notification struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Type NotificationType `json:"type" gorm:"index"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Read bool `json:"read" gorm:"index"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"index"`
|
||||
}
|
||||
|
||||
func (n *Notification) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if n.ID == "" {
|
||||
n.ID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NotificationConfig stores configuration for security notifications.
|
||||
type NotificationConfig struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MinLogLevel string `json:"min_log_level"` // error, warn, info, debug
|
||||
WebhookURL string `json:"webhook_url"`
|
||||
// Blocker 2 Fix: API surface uses security_* field names per spec (internal fields remain notify_*)
|
||||
NotifyWAFBlocks bool `json:"security_waf_enabled"`
|
||||
NotifyACLDenies bool `json:"security_acl_enabled"`
|
||||
NotifyRateLimitHits bool `json:"security_rate_limit_enabled"`
|
||||
NotifyCrowdSecDecisions bool `json:"security_crowdsec_enabled"`
|
||||
EmailRecipients string `json:"email_recipients"`
|
||||
|
||||
// Legacy destination fields (compatibility, not stored in DB)
|
||||
DiscordWebhookURL string `gorm:"-" json:"discord_webhook_url,omitempty"`
|
||||
SlackWebhookURL string `gorm:"-" json:"slack_webhook_url,omitempty"`
|
||||
GotifyURL string `gorm:"-" json:"gotify_url,omitempty"`
|
||||
GotifyToken string `gorm:"-" json:"-"` // Security: Never expose token in JSON (OWASP A02)
|
||||
DestinationAmbiguous bool `gorm:"-" json:"destination_ambiguous,omitempty"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BeforeCreate sets the ID if not already set.
|
||||
func (nc *NotificationConfig) BeforeCreate(tx *gorm.DB) error {
|
||||
if nc.ID == "" {
|
||||
nc.ID = uuid.New().String()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SecurityEvent represents a security event for notification dispatch.
|
||||
type SecurityEvent struct {
|
||||
EventType string `json:"event_type"` // waf_block, acl_deny, etc.
|
||||
Severity string `json:"severity"` // error, warn, info
|
||||
Message string `json:"message"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Path string `json:"path"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type NotificationProvider struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Type string `json:"type" gorm:"index"` // discord (only supported type in current rollout)
|
||||
URL string `json:"url"` // Discord webhook URL (HTTPS format required)
|
||||
Token string `json:"-"` // Auth token for providers (e.g., Gotify) - never exposed in API
|
||||
HasToken bool `json:"has_token" gorm:"-"` // Computed: indicates whether a token is set (never exposes raw value)
|
||||
Engine string `json:"engine,omitempty" gorm:"index"` // notify_v1 (notify-only runtime)
|
||||
Config string `json:"config"` // JSON payload template for custom webhooks
|
||||
ServiceConfig string `json:"service_config,omitempty" gorm:"type:text"` // JSON blob for typed service config
|
||||
LegacyURL string `json:"legacy_url,omitempty"` // Audit field: preserved original URL during migration
|
||||
Template string `json:"template" gorm:"default:minimal"` // minimal|detailed|custom
|
||||
MigrationState string `json:"migration_state,omitempty" gorm:"index"` // pending | migrated | deprecated
|
||||
MigrationError string `json:"migration_error,omitempty" gorm:"type:text"`
|
||||
LastMigratedAt *time.Time `json:"last_migrated_at,omitempty"`
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
|
||||
// Notification Preferences
|
||||
NotifyProxyHosts bool `json:"notify_proxy_hosts" gorm:"default:true"`
|
||||
NotifyRemoteServers bool `json:"notify_remote_servers" gorm:"default:true"`
|
||||
NotifyDomains bool `json:"notify_domains" gorm:"default:true"`
|
||||
NotifyCerts bool `json:"notify_certs" gorm:"default:true"`
|
||||
NotifyUptime bool `json:"notify_uptime" gorm:"default:true"`
|
||||
|
||||
// Security Event Notifications (Provider-based)
|
||||
NotifySecurityWAFBlocks bool `json:"notify_security_waf_blocks" gorm:"default:false"`
|
||||
NotifySecurityACLDenies bool `json:"notify_security_acl_denies" gorm:"default:false"`
|
||||
NotifySecurityRateLimitHits bool `json:"notify_security_rate_limit_hits" gorm:"default:false"`
|
||||
NotifySecurityCrowdSecDecisions bool `json:"notify_security_crowdsec_decisions" gorm:"column:notify_security_crowdsec_decisions;default:false"`
|
||||
|
||||
// Managed Legacy Provider Marker
|
||||
ManagedLegacySecurity bool `json:"managed_legacy_security" gorm:"index;default:false"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (n *NotificationProvider) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if n.ID == "" {
|
||||
n.ID = uuid.New().String()
|
||||
}
|
||||
// Set defaults if not explicitly set (though gorm default tag handles DB side)
|
||||
// We can't easily distinguish between false and unset for bools here without pointers,
|
||||
// but for new creations via API, we can assume the frontend sends what it wants.
|
||||
// If we wanted to force defaults in Go:
|
||||
// n.NotifyProxyHosts = true ...
|
||||
if strings.TrimSpace(n.Template) == "" {
|
||||
if strings.TrimSpace(n.Config) != "" {
|
||||
n.Template = "custom"
|
||||
} else {
|
||||
n.Template = "minimal"
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestNotificationProvider_BeforeCreate(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.NotificationProvider{}))
|
||||
|
||||
provider := models.NotificationProvider{
|
||||
Name: "Test",
|
||||
}
|
||||
err = db.Create(&provider).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, provider.ID)
|
||||
// Check default template is minimal if Config is empty
|
||||
assert.Equal(t, "minimal", provider.Template)
|
||||
|
||||
// If Config is present, Template default should be 'custom'
|
||||
provider2 := models.NotificationProvider{
|
||||
Name: "Test2",
|
||||
Config: `{"custom":"ok"}`,
|
||||
}
|
||||
err = db.Create(&provider2).Error
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "custom", provider2.Template)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NotificationTemplate represents a reusable external notification template
|
||||
// that can be applied when sending webhooks or other external notifications.
|
||||
type NotificationTemplate struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
// Config holds the JSON/template body for external webhook payloads
|
||||
Config string `json:"config"`
|
||||
// Template is a hint: minimal|detailed|custom (optional)
|
||||
Template string `json:"template" gorm:"default:minimal"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (t *NotificationTemplate) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if t.ID == "" {
|
||||
t.ID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestNotification_BeforeCreate(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
_ = db.AutoMigrate(&Notification{})
|
||||
|
||||
// Case 1: ID is empty, should be generated
|
||||
n1 := &Notification{Title: "Test", Message: "Test Message"}
|
||||
err = db.Create(n1).Error
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, n1.ID)
|
||||
|
||||
// Case 2: ID is provided, should be kept
|
||||
id := "123e4567-e89b-12d3-a456-426614174000"
|
||||
n2 := &Notification{ID: id, Title: "Test 2", Message: "Test Message 2"}
|
||||
err = db.Create(n2).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, id, n2.ID)
|
||||
}
|
||||
|
||||
func TestNotificationConfig_BeforeCreate(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
_ = db.AutoMigrate(&NotificationConfig{})
|
||||
|
||||
// Case 1: ID is empty, should be generated
|
||||
nc1 := &NotificationConfig{Enabled: true, MinLogLevel: "error"}
|
||||
err = db.Create(nc1).Error
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, nc1.ID)
|
||||
|
||||
// Case 2: ID is provided, should be kept
|
||||
id := "custom-config-id"
|
||||
nc2 := &NotificationConfig{ID: id, Enabled: false, MinLogLevel: "warn"}
|
||||
err = db.Create(nc2).Error
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, id, nc2.ID)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Plugin represents an installed DNS provider plugin.
|
||||
// This tracks both external .so plugins and their load status.
|
||||
type Plugin struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;size:36"`
|
||||
Name string `json:"name" gorm:"not null;size:255"`
|
||||
Type string `json:"type" gorm:"uniqueIndex;not null;size:100"`
|
||||
FilePath string `json:"file_path" gorm:"not null;size:500"`
|
||||
Signature string `json:"signature" gorm:"size:100"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
Status string `json:"status" gorm:"default:'pending';size:50"` // pending, loaded, error
|
||||
Error string `json:"error,omitempty" gorm:"type:text"`
|
||||
Version string `json:"version,omitempty" gorm:"size:50"`
|
||||
Author string `json:"author,omitempty" gorm:"size:255"`
|
||||
|
||||
LoadedAt *time.Time `json:"loaded_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the database table name for GORM.
|
||||
func (Plugin) TableName() string {
|
||||
return "plugins"
|
||||
}
|
||||
|
||||
// PluginStatus constants define the possible status values for a plugin.
|
||||
const (
|
||||
PluginStatusPending = "pending" // Plugin registered but not yet loaded
|
||||
PluginStatusLoaded = "loaded" // Plugin successfully loaded and registered
|
||||
PluginStatusError = "error" // Plugin failed to load
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProxyHost represents a reverse proxy configuration.
|
||||
type ProxyHost struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
DomainNames string `json:"domain_names" gorm:"not null;index"` // Comma-separated list
|
||||
ForwardScheme string `json:"forward_scheme" gorm:"default:http"`
|
||||
ForwardHost string `json:"forward_host" gorm:"not null;index"`
|
||||
ForwardPort int `json:"forward_port" gorm:"not null"`
|
||||
SSLForced bool `json:"ssl_forced" gorm:"default:false"`
|
||||
HTTP2Support bool `json:"http2_support" gorm:"default:true"`
|
||||
HSTSEnabled bool `json:"hsts_enabled" gorm:"default:false"`
|
||||
HSTSSubdomains bool `json:"hsts_subdomains" gorm:"default:false"`
|
||||
BlockExploits bool `json:"block_exploits" gorm:"default:true"`
|
||||
WebsocketSupport bool `json:"websocket_support" gorm:"default:false"`
|
||||
Application string `json:"application" gorm:"default:none"` // none, plex, jellyfin, emby, homeassistant, nextcloud, vaultwarden
|
||||
Enabled bool `json:"enabled" gorm:"default:true;index"`
|
||||
CertificateID *uint `json:"certificate_id" gorm:"index"`
|
||||
Certificate *SSLCertificate `json:"certificate" gorm:"foreignKey:CertificateID"`
|
||||
AccessListID *uint `json:"access_list_id" gorm:"index"`
|
||||
AccessList *AccessList `json:"access_list" gorm:"foreignKey:AccessListID"`
|
||||
Locations []Location `json:"locations" gorm:"foreignKey:ProxyHostID;constraint:OnDelete:CASCADE"`
|
||||
AdvancedConfig string `json:"advanced_config" gorm:"type:text"`
|
||||
AdvancedConfigBackup string `json:"advanced_config_backup" gorm:"type:text"`
|
||||
|
||||
// Forward Auth / User Gateway settings
|
||||
// When enabled, Caddy will use forward_auth to verify user access via Charon
|
||||
ForwardAuthEnabled bool `json:"forward_auth_enabled" gorm:"default:false"`
|
||||
|
||||
// WAF override - when true, disables WAF for this specific host
|
||||
WAFDisabled bool `json:"waf_disabled" gorm:"default:false"`
|
||||
|
||||
// Security Headers Configuration
|
||||
// Either reference a profile OR use inline settings
|
||||
SecurityHeaderProfileID *uint `json:"security_header_profile_id" gorm:"index"`
|
||||
SecurityHeaderProfile *SecurityHeaderProfile `json:"security_header_profile" gorm:"foreignKey:SecurityHeaderProfileID"`
|
||||
|
||||
// Inline security header settings (used when no profile is selected)
|
||||
// These override profile settings if both are set
|
||||
SecurityHeadersEnabled bool `json:"security_headers_enabled" gorm:"default:true"`
|
||||
SecurityHeadersCustom string `json:"security_headers_custom" gorm:"type:text"` // JSON for custom headers
|
||||
|
||||
// EnableStandardHeaders controls whether standard proxy headers are added
|
||||
// Default: true for NEW hosts, false for EXISTING hosts (via migration/seed update)
|
||||
// When true: Adds X-Real-IP, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port
|
||||
// When false: Old behavior (headers only with WebSocket or application-specific)
|
||||
// X-Forwarded-For is handled natively by Caddy (not explicitly set)
|
||||
EnableStandardHeaders *bool `json:"enable_standard_headers,omitempty" gorm:"default:true"`
|
||||
|
||||
// DNS Challenge configuration
|
||||
DNSProviderID *uint `json:"dns_provider_id,omitempty" gorm:"index"`
|
||||
DNSProvider *DNSProvider `json:"dns_provider,omitempty" gorm:"foreignKey:DNSProviderID"`
|
||||
UseDNSChallenge bool `json:"use_dns_challenge" gorm:"default:false"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// RemoteServer represents a known backend server that can be selected
|
||||
// when creating proxy hosts, eliminating manual IP/port entry.
|
||||
type RemoteServer struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Provider string `json:"provider" gorm:"index"` // e.g., "docker", "vm", "cloud", "manual"
|
||||
Host string `json:"host" gorm:"index"` // IP address or hostname
|
||||
Port int `json:"port"`
|
||||
Scheme string `json:"scheme"` // http/https
|
||||
Tags string `json:"tags"` // comma-separated tags for filtering
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true;index"`
|
||||
LastChecked *time.Time `json:"last_checked,omitempty"`
|
||||
Reachable bool `json:"reachable" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityAudit records admin actions or important changes related to security.
|
||||
type SecurityAudit struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Actor string `json:"actor" gorm:"index"`
|
||||
Action string `json:"action"`
|
||||
EventCategory string `json:"event_category" gorm:"index"`
|
||||
ResourceID *uint `json:"resource_id,omitempty"`
|
||||
ResourceUUID string `json:"resource_uuid,omitempty" gorm:"index"`
|
||||
Details string `json:"details" gorm:"type:text"`
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
UserAgent string `json:"user_agent,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"index"`
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityConfig represents global Cerberus/CrowdSec/WAF/RateLimit settings
|
||||
// used by the server and propagated into the generated Caddy config.
|
||||
type SecurityConfig struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
AdminWhitelist string `json:"admin_whitelist" gorm:"type:text"` // JSON array or comma-separated CIDRs
|
||||
BreakGlassHash string `json:"-" gorm:"column:break_glass_hash"`
|
||||
CrowdSecMode string `json:"crowdsec_mode"` // "disabled" or "local"
|
||||
CrowdSecAPIURL string `json:"crowdsec_api_url" gorm:"type:text"`
|
||||
WAFMode string `json:"waf_mode"` // "disabled", "monitor", "block"
|
||||
WAFRulesSource string `json:"waf_rules_source" gorm:"type:text"` // URL or name of ruleset
|
||||
WAFLearning bool `json:"waf_learning"`
|
||||
WAFParanoiaLevel int `json:"waf_paranoia_level" gorm:"default:1"` // 1-4, OWASP CRS paranoia level
|
||||
WAFExclusions string `json:"waf_exclusions" gorm:"type:text"` // JSON array of rule exclusions
|
||||
RateLimitMode string `json:"rate_limit_mode"` // "disabled", "enabled"
|
||||
RateLimitEnable bool `json:"rate_limit_enable"`
|
||||
RateLimitBurst int `json:"rate_limit_burst"`
|
||||
RateLimitRequests int `json:"rate_limit_requests"`
|
||||
RateLimitWindowSec int `json:"rate_limit_window_sec"`
|
||||
RateLimitBypassList string `json:"rate_limit_bypass_list" gorm:"type:text"` // Comma-separated CIDRs
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityDecision stores a decision/action taken by CrowdSec/WAF/RateLimit or manual
|
||||
// override so it can be audited and surfaced in the UI.
|
||||
type SecurityDecision struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Source string `json:"source" gorm:"index"` // e.g., crowdsec, waf, ratelimit, manual
|
||||
Action string `json:"action" gorm:"index"` // allow, block, challenge
|
||||
IP string `json:"ip" gorm:"index"`
|
||||
Host string `json:"host" gorm:"index"` // optional
|
||||
RuleID string `json:"rule_id" gorm:"index"`
|
||||
Details string `json:"details" gorm:"type:text"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"index"`
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityHeaderProfile stores reusable security header configurations.
|
||||
// Users can create profiles and assign them to proxy hosts.
|
||||
type SecurityHeaderProfile struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name" gorm:"index;not null"`
|
||||
|
||||
// HSTS Configuration
|
||||
HSTSEnabled bool `json:"hsts_enabled" gorm:"default:true"`
|
||||
HSTSMaxAge int `json:"hsts_max_age" gorm:"default:31536000"` // 1 year in seconds
|
||||
HSTSIncludeSubdomains bool `json:"hsts_include_subdomains" gorm:"default:true"`
|
||||
HSTSPreload bool `json:"hsts_preload" gorm:"default:false"`
|
||||
|
||||
// Content-Security-Policy
|
||||
CSPEnabled bool `json:"csp_enabled" gorm:"default:false"`
|
||||
CSPDirectives string `json:"csp_directives" gorm:"type:text"` // JSON object of CSP directives
|
||||
CSPReportOnly bool `json:"csp_report_only" gorm:"default:false"`
|
||||
CSPReportURI string `json:"csp_report_uri"`
|
||||
|
||||
// X-Frame-Options
|
||||
XFrameOptions string `json:"x_frame_options" gorm:"default:DENY"` // DENY, SAMEORIGIN, or empty
|
||||
|
||||
// X-Content-Type-Options
|
||||
XContentTypeOptions bool `json:"x_content_type_options" gorm:"default:true"` // nosniff
|
||||
|
||||
// Referrer-Policy
|
||||
ReferrerPolicy string `json:"referrer_policy" gorm:"default:strict-origin-when-cross-origin"`
|
||||
|
||||
// Permissions-Policy (formerly Feature-Policy)
|
||||
PermissionsPolicy string `json:"permissions_policy" gorm:"type:text"` // JSON array of policies
|
||||
|
||||
// Cross-Origin Headers
|
||||
CrossOriginOpenerPolicy string `json:"cross_origin_opener_policy" gorm:"default:same-origin"`
|
||||
CrossOriginResourcePolicy string `json:"cross_origin_resource_policy" gorm:"default:same-origin"`
|
||||
CrossOriginEmbedderPolicy string `json:"cross_origin_embedder_policy"` // require-corp or empty
|
||||
|
||||
// X-XSS-Protection (legacy but still useful)
|
||||
XSSProtection bool `json:"xss_protection" gorm:"default:true"`
|
||||
|
||||
// Cache-Control for security
|
||||
CacheControlNoStore bool `json:"cache_control_no_store" gorm:"default:false"`
|
||||
|
||||
// Computed Security Score (0-100)
|
||||
SecurityScore int `json:"security_score" gorm:"default:0"`
|
||||
|
||||
// Metadata
|
||||
IsPreset bool `json:"is_preset" gorm:"default:false"` // System presets can't be deleted
|
||||
PresetType string `json:"preset_type"` // "basic", "strict", "paranoid", or empty for custom
|
||||
Description string `json:"description" gorm:"type:text"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CSPDirective represents a single CSP directive for the builder
|
||||
type CSPDirective struct {
|
||||
Directive string `json:"directive"` // e.g., "default-src", "script-src"
|
||||
Values []string `json:"values"` // e.g., ["'self'", "https:"]
|
||||
}
|
||||
|
||||
// PermissionsPolicyItem represents a single Permissions-Policy entry
|
||||
type PermissionsPolicyItem struct {
|
||||
Feature string `json:"feature"` // e.g., "camera", "microphone"
|
||||
Allowlist []string `json:"allowlist"` // e.g., ["self"], ["*"], []
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupSecurityHeaderProfileDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = db.AutoMigrate(&SecurityHeaderProfile{})
|
||||
assert.NoError(t, err)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSecurityHeaderProfile_Create(t *testing.T) {
|
||||
db := setupSecurityHeaderProfileDB(t)
|
||||
|
||||
profile := SecurityHeaderProfile{
|
||||
UUID: uuid.New().String(),
|
||||
Name: "Test Profile",
|
||||
HSTSEnabled: true,
|
||||
HSTSMaxAge: 31536000,
|
||||
HSTSIncludeSubdomains: true,
|
||||
HSTSPreload: false,
|
||||
CSPEnabled: false,
|
||||
XFrameOptions: "DENY",
|
||||
XContentTypeOptions: true,
|
||||
ReferrerPolicy: "strict-origin-when-cross-origin",
|
||||
XSSProtection: true,
|
||||
SecurityScore: 65,
|
||||
IsPreset: false,
|
||||
}
|
||||
|
||||
err := db.Create(&profile).Error
|
||||
assert.NoError(t, err)
|
||||
assert.NotZero(t, profile.ID)
|
||||
}
|
||||
|
||||
func TestSecurityHeaderProfile_JSONSerialization(t *testing.T) {
|
||||
profile := SecurityHeaderProfile{
|
||||
ID: 1,
|
||||
UUID: "test-uuid",
|
||||
Name: "Test Profile",
|
||||
HSTSEnabled: true,
|
||||
HSTSMaxAge: 31536000,
|
||||
HSTSIncludeSubdomains: true,
|
||||
XFrameOptions: "DENY",
|
||||
SecurityScore: 85,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(profile)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(data), `"hsts_enabled":true`)
|
||||
assert.Contains(t, string(data), `"hsts_max_age":31536000`)
|
||||
assert.Contains(t, string(data), `"x_frame_options":"DENY"`)
|
||||
|
||||
var decoded SecurityHeaderProfile
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, profile.Name, decoded.Name)
|
||||
assert.Equal(t, profile.HSTSEnabled, decoded.HSTSEnabled)
|
||||
assert.Equal(t, profile.SecurityScore, decoded.SecurityScore)
|
||||
}
|
||||
|
||||
func TestCSPDirective_JSONSerialization(t *testing.T) {
|
||||
directive := CSPDirective{
|
||||
Directive: "default-src",
|
||||
Values: []string{"'self'", "https:"},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(directive)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(data), `"directive":"default-src"`)
|
||||
assert.Contains(t, string(data), `"values":["'self'","https:"]`)
|
||||
|
||||
var decoded CSPDirective
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, directive.Directive, decoded.Directive)
|
||||
assert.Equal(t, directive.Values, decoded.Values)
|
||||
}
|
||||
|
||||
func TestPermissionsPolicyItem_JSONSerialization(t *testing.T) {
|
||||
item := PermissionsPolicyItem{
|
||||
Feature: "camera",
|
||||
Allowlist: []string{"self"},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(item)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, string(data), `"feature":"camera"`)
|
||||
assert.Contains(t, string(data), `"allowlist":["self"]`)
|
||||
|
||||
var decoded PermissionsPolicyItem
|
||||
err = json.Unmarshal(data, &decoded)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, item.Feature, decoded.Feature)
|
||||
assert.Equal(t, item.Allowlist, decoded.Allowlist)
|
||||
}
|
||||
|
||||
func TestSecurityHeaderProfile_Defaults(t *testing.T) {
|
||||
db := setupSecurityHeaderProfileDB(t)
|
||||
|
||||
profile := SecurityHeaderProfile{
|
||||
UUID: uuid.New().String(),
|
||||
Name: "Default Test",
|
||||
}
|
||||
|
||||
err := db.Create(&profile).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Reload to check defaults
|
||||
var reloaded SecurityHeaderProfile
|
||||
err = db.First(&reloaded, profile.ID).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.True(t, reloaded.HSTSEnabled)
|
||||
assert.Equal(t, 31536000, reloaded.HSTSMaxAge)
|
||||
assert.True(t, reloaded.HSTSIncludeSubdomains)
|
||||
assert.False(t, reloaded.HSTSPreload)
|
||||
assert.False(t, reloaded.CSPEnabled)
|
||||
assert.Equal(t, "DENY", reloaded.XFrameOptions)
|
||||
assert.True(t, reloaded.XContentTypeOptions)
|
||||
assert.Equal(t, "strict-origin-when-cross-origin", reloaded.ReferrerPolicy)
|
||||
assert.True(t, reloaded.XSSProtection)
|
||||
assert.False(t, reloaded.CacheControlNoStore)
|
||||
assert.Equal(t, 0, reloaded.SecurityScore)
|
||||
assert.False(t, reloaded.IsPreset)
|
||||
}
|
||||
|
||||
func TestSecurityHeaderProfile_UniqueUUID(t *testing.T) {
|
||||
db := setupSecurityHeaderProfileDB(t)
|
||||
|
||||
testUUID := uuid.New().String()
|
||||
|
||||
profile1 := SecurityHeaderProfile{
|
||||
UUID: testUUID,
|
||||
Name: "Profile 1",
|
||||
}
|
||||
err := db.Create(&profile1).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
profile2 := SecurityHeaderProfile{
|
||||
UUID: testUUID,
|
||||
Name: "Profile 2",
|
||||
}
|
||||
err = db.Create(&profile2).Error
|
||||
assert.Error(t, err) // Should fail due to unique constraint
|
||||
}
|
||||
|
||||
func TestSecurityHeaderProfile_CSPDirectivesStorage(t *testing.T) {
|
||||
db := setupSecurityHeaderProfileDB(t)
|
||||
|
||||
cspDirectives := map[string][]string{
|
||||
"default-src": {"'self'"},
|
||||
"script-src": {"'self'", "'unsafe-inline'"},
|
||||
"style-src": {"'self'", "https:"},
|
||||
}
|
||||
cspJSON, err := json.Marshal(cspDirectives)
|
||||
assert.NoError(t, err)
|
||||
|
||||
profile := SecurityHeaderProfile{
|
||||
UUID: uuid.New().String(),
|
||||
Name: "CSP Test",
|
||||
CSPEnabled: true,
|
||||
CSPDirectives: string(cspJSON),
|
||||
}
|
||||
|
||||
err = db.Create(&profile).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Reload and verify
|
||||
var reloaded SecurityHeaderProfile
|
||||
err = db.First(&reloaded, profile.ID).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
var decoded map[string][]string
|
||||
err = json.Unmarshal([]byte(reloaded.CSPDirectives), &decoded)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, cspDirectives, decoded)
|
||||
}
|
||||
|
||||
func TestSecurityHeaderProfile_PermissionsPolicyStorage(t *testing.T) {
|
||||
db := setupSecurityHeaderProfileDB(t)
|
||||
|
||||
permissions := []PermissionsPolicyItem{
|
||||
{Feature: "camera", Allowlist: []string{}},
|
||||
{Feature: "microphone", Allowlist: []string{"self"}},
|
||||
{Feature: "geolocation", Allowlist: []string{"*"}},
|
||||
}
|
||||
permJSON, err := json.Marshal(permissions)
|
||||
assert.NoError(t, err)
|
||||
|
||||
profile := SecurityHeaderProfile{
|
||||
UUID: uuid.New().String(),
|
||||
Name: "Permissions Test",
|
||||
PermissionsPolicy: string(permJSON),
|
||||
}
|
||||
|
||||
err = db.Create(&profile).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Reload and verify
|
||||
var reloaded SecurityHeaderProfile
|
||||
err = db.First(&reloaded, profile.ID).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
var decoded []PermissionsPolicyItem
|
||||
err = json.Unmarshal([]byte(reloaded.PermissionsPolicy), &decoded)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, permissions, decoded)
|
||||
}
|
||||
|
||||
func TestSecurityHeaderProfile_PresetFields(t *testing.T) {
|
||||
db := setupSecurityHeaderProfileDB(t)
|
||||
|
||||
profile := SecurityHeaderProfile{
|
||||
UUID: uuid.New().String(),
|
||||
Name: "Basic Security",
|
||||
IsPreset: true,
|
||||
PresetType: "basic",
|
||||
Description: "Essential security headers for most websites",
|
||||
}
|
||||
|
||||
err := db.Create(&profile).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Reload
|
||||
var reloaded SecurityHeaderProfile
|
||||
err = db.First(&reloaded, profile.ID).Error
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.True(t, reloaded.IsPreset)
|
||||
assert.Equal(t, "basic", reloaded.PresetType)
|
||||
assert.Equal(t, "Essential security headers for most websites", reloaded.Description)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Package models defines the data types used throughout the application.
|
||||
package models
|
||||
|
||||
// SecurityLogEntry represents a security-relevant log entry for live streaming.
|
||||
// This struct is used by the LogWatcher service to broadcast parsed Caddy access logs
|
||||
// with security event annotations to WebSocket clients.
|
||||
type SecurityLogEntry struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Level string `json:"level"`
|
||||
Logger string `json:"logger"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Method string `json:"method"`
|
||||
URI string `json:"uri"`
|
||||
Status int `json:"status"`
|
||||
Duration float64 `json:"duration"`
|
||||
Size int64 `json:"size"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Host string `json:"host"`
|
||||
Source string `json:"source"` // "waf", "crowdsec", "ratelimit", "acl", "normal"
|
||||
Blocked bool `json:"blocked"` // True if request was blocked
|
||||
BlockReason string `json:"block_reason,omitempty"` // Reason for blocking
|
||||
Details map[string]any `json:"details,omitempty"` // Additional metadata
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SecurityRuleSet stores metadata about WAF/CrowdSec rule sets that the server can download and apply.
|
||||
type SecurityRuleSet struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
SourceURL string `json:"source_url" gorm:"type:text"`
|
||||
Mode string `json:"mode"` // optional e.g., 'owasp', 'custom'
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
Content string `json:"content" gorm:"type:text"`
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Setting stores global application configuration as key-value pairs.
|
||||
// Used for system-wide preferences, feature flags, and runtime config.
|
||||
type Setting struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
Key string `json:"key" gorm:"uniqueIndex"`
|
||||
Value string `json:"value" gorm:"type:text"`
|
||||
Type string `json:"type" gorm:"index"` // "string", "int", "bool", "json"
|
||||
Category string `json:"category" gorm:"index"` // "general", "security", "caddy", "smtp", etc.
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SSLCertificate represents TLS certificates managed by Charon.
|
||||
// Can be Let's Encrypt auto-generated or custom uploaded certs.
|
||||
type SSLCertificate struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Provider string `json:"provider" gorm:"index"` // "letsencrypt", "custom", "self-signed"
|
||||
Domains string `json:"domains" gorm:"index"` // comma-separated list of domains
|
||||
Certificate string `json:"certificate" gorm:"type:text"` // PEM-encoded certificate
|
||||
PrivateKey string `json:"private_key" gorm:"type:text"` // PEM-encoded private key
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"`
|
||||
AutoRenew bool `json:"auto_renew" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UptimeMonitor struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
ProxyHostID *uint `json:"proxy_host_id" gorm:"index"` // Optional link to proxy host
|
||||
ProxyHost *ProxyHost `json:"proxy_host,omitempty" gorm:"foreignKey:ProxyHostID"` // Relationship for automatic loading
|
||||
RemoteServerID *uint `json:"remote_server_id" gorm:"index"` // Optional link to remote server
|
||||
UptimeHostID *string `json:"uptime_host_id" gorm:"index"` // Link to parent host for grouping
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Type string `json:"type"` // http, tcp, ping
|
||||
URL string `json:"url"`
|
||||
UpstreamHost string `json:"upstream_host" gorm:"index"` // The actual backend host/IP (for grouping)
|
||||
Interval int `json:"interval"` // seconds
|
||||
Enabled bool `json:"enabled" gorm:"index"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Current Status (Cached)
|
||||
Status string `json:"status" gorm:"index"` // up, down, maintenance, pending
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
Latency int64 `json:"latency"` // ms
|
||||
FailureCount int `json:"failure_count"`
|
||||
LastStatusChange time.Time `json:"last_status_change"`
|
||||
MaxRetries int `json:"max_retries" gorm:"default:3"`
|
||||
|
||||
// Notification tracking
|
||||
LastNotifiedDown time.Time `json:"last_notified_down"` // Prevent duplicate notifications
|
||||
NotifiedInBatch bool `json:"notified_in_batch"` // Was this included in a batch notification?
|
||||
}
|
||||
|
||||
type UptimeHeartbeat struct {
|
||||
ID uint `gorm:"primaryKey" json:"-"`
|
||||
MonitorID string `json:"monitor_id" gorm:"index"`
|
||||
Status string `json:"status"` // up, down
|
||||
Latency int64 `json:"latency"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"index"`
|
||||
}
|
||||
|
||||
func (m *UptimeMonitor) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if m.ID == "" {
|
||||
m.ID = uuid.New().String()
|
||||
}
|
||||
if m.Status == "" {
|
||||
m.Status = "pending"
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// UptimeHost represents a unique upstream host/IP that may have multiple services.
|
||||
// This enables host-level health checks to avoid notification storms when a whole server goes down.
|
||||
type UptimeHost struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
Host string `json:"host" gorm:"uniqueIndex;not null"` // IP address or hostname
|
||||
Name string `json:"name"` // Friendly name (auto-generated or from first service)
|
||||
Status string `json:"status"` // up, down, pending
|
||||
LastCheck time.Time `json:"last_check"`
|
||||
Latency int64 `json:"latency"` // ms for ping/TCP check
|
||||
|
||||
// Notification tracking
|
||||
LastNotifiedDown time.Time `json:"last_notified_down"` // When we last sent DOWN notification
|
||||
LastNotifiedUp time.Time `json:"last_notified_up"` // When we last sent UP notification
|
||||
NotifiedServiceCount int `json:"notified_service_count"` // Number of services in last notification
|
||||
LastStatusChange time.Time `json:"last_status_change"` // When status last changed
|
||||
FailureCount int `json:"failure_count" gorm:"default:0"` // Consecutive failures for debouncing
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (h *UptimeHost) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if h.ID == "" {
|
||||
h.ID = uuid.New().String()
|
||||
}
|
||||
if h.Status == "" {
|
||||
h.Status = "pending"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// UptimeNotificationEvent tracks notification batches to prevent duplicates
|
||||
type UptimeNotificationEvent struct {
|
||||
ID string `gorm:"primaryKey" json:"id"`
|
||||
HostID string `json:"host_id" gorm:"index"`
|
||||
EventType string `json:"event_type"` // down, up, partial_recovery
|
||||
MonitorIDs string `json:"monitor_ids"` // JSON array of monitor IDs included in this notification
|
||||
Message string `json:"message"`
|
||||
SentAt time.Time `json:"sent_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (e *UptimeNotificationEvent) BeforeCreate(tx *gorm.DB) (err error) {
|
||||
if e.ID == "" {
|
||||
e.ID = uuid.New().String()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestUptimeMonitor_BeforeCreate(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.UptimeMonitor{}))
|
||||
|
||||
monitor := models.UptimeMonitor{
|
||||
Name: "Test",
|
||||
}
|
||||
err = db.Create(&monitor).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, monitor.ID)
|
||||
assert.Equal(t, "pending", monitor.Status)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Package models defines the database schema and domain types.
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// UserRole represents an authenticated user's privilege tier.
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
// RoleAdmin has full access to all Charon features and management.
|
||||
RoleAdmin UserRole = "admin"
|
||||
// RoleUser can access the Charon management UI with restricted permissions.
|
||||
RoleUser UserRole = "user"
|
||||
// RolePassthrough can only authenticate for forward-auth proxy access.
|
||||
RolePassthrough UserRole = "passthrough"
|
||||
)
|
||||
|
||||
// IsValid returns true when the role is one of the recognised privilege tiers.
|
||||
func (r UserRole) IsValid() bool {
|
||||
switch r {
|
||||
case RoleAdmin, RoleUser, RolePassthrough:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PermissionMode determines how user access to proxy hosts is evaluated.
|
||||
type PermissionMode string
|
||||
|
||||
const (
|
||||
// PermissionModeAllowAll grants access to all hosts except those in the exception list.
|
||||
PermissionModeAllowAll PermissionMode = "allow_all"
|
||||
// PermissionModeDenyAll denies access to all hosts except those in the exception list.
|
||||
PermissionModeDenyAll PermissionMode = "deny_all"
|
||||
)
|
||||
|
||||
// User represents authenticated users with role-based access control.
|
||||
// Supports local auth, SSO integration, and invite-based onboarding.
|
||||
type User struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Email string `json:"email" gorm:"uniqueIndex"`
|
||||
APIKey string `json:"-" gorm:"uniqueIndex"` // For external API access, never exposed in JSON
|
||||
PasswordHash string `json:"-"` // Never serialize password hash
|
||||
Name string `json:"name"`
|
||||
Role UserRole `json:"role" gorm:"default:'user'"`
|
||||
Enabled bool `json:"enabled" gorm:"default:true"`
|
||||
FailedLoginAttempts int `json:"-" gorm:"default:0"`
|
||||
LockedUntil *time.Time `json:"-"`
|
||||
LastLogin *time.Time `json:"last_login,omitempty"`
|
||||
SessionVersion uint `json:"-" gorm:"default:0"`
|
||||
|
||||
// Invite system fields
|
||||
InviteToken string `json:"-" gorm:"index"` // Token sent via email for account setup
|
||||
InviteExpires *time.Time `json:"-"` // When the invite token expires
|
||||
InvitedAt *time.Time `json:"invited_at,omitempty"` // When the invite was sent
|
||||
InvitedBy *uint `json:"invited_by,omitempty"` // ID of user who sent the invite
|
||||
InviteStatus string `json:"invite_status,omitempty"` // "pending", "accepted", "expired"
|
||||
|
||||
// Permission system for forward auth / user gateway
|
||||
PermissionMode PermissionMode `json:"permission_mode" gorm:"default:'allow_all'"` // "allow_all" or "deny_all"
|
||||
PermittedHosts []ProxyHost `json:"permitted_hosts,omitempty" gorm:"many2many:user_permitted_hosts;"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SetPassword hashes and sets the user's password.
|
||||
func (u *User) SetPassword(password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.PasswordHash = string(hash)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckPassword compares the provided password with the stored hash.
|
||||
func (u *User) CheckPassword(password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// HasPendingInvite returns true if the user has a pending invite that hasn't expired.
|
||||
func (u *User) HasPendingInvite() bool {
|
||||
if u.InviteToken == "" || u.InviteExpires == nil {
|
||||
return false
|
||||
}
|
||||
return u.InviteExpires.After(time.Now()) && u.InviteStatus == "pending"
|
||||
}
|
||||
|
||||
// CanAccessHost determines if the user can access a given proxy host based on their permission mode.
|
||||
// - allow_all mode: User can access everything EXCEPT hosts in PermittedHosts (blacklist)
|
||||
// - deny_all mode: User can ONLY access hosts in PermittedHosts (whitelist)
|
||||
func (u *User) CanAccessHost(hostID uint) bool {
|
||||
// Admins always have access
|
||||
if u.Role == RoleAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if host is in the permitted hosts list
|
||||
hostInList := false
|
||||
for _, h := range u.PermittedHosts {
|
||||
if h.ID == hostID {
|
||||
hostInList = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
switch u.PermissionMode {
|
||||
case PermissionModeAllowAll:
|
||||
// Allow all except those in the list (blacklist)
|
||||
return !hostInList
|
||||
case PermissionModeDenyAll:
|
||||
// Deny all except those in the list (whitelist)
|
||||
return hostInList
|
||||
default:
|
||||
// Default to allow_all behavior
|
||||
return !hostInList
|
||||
}
|
||||
}
|
||||
|
||||
// UserPermittedHost is the join table for the many-to-many relationship.
|
||||
// This is auto-created by GORM but defined here for clarity.
|
||||
type UserPermittedHost struct {
|
||||
UserID uint `gorm:"primaryKey"`
|
||||
ProxyHostID uint `gorm:"primaryKey"`
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUser_SetPassword(t *testing.T) {
|
||||
u := &User{}
|
||||
err := u.SetPassword("password123")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, u.PasswordHash)
|
||||
assert.NotEqual(t, "password123", u.PasswordHash)
|
||||
|
||||
// Test with empty password (should still work but hash empty string)
|
||||
u2 := &User{}
|
||||
err = u2.SetPassword("")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, u2.PasswordHash)
|
||||
|
||||
// Test with special characters
|
||||
u3 := &User{}
|
||||
err = u3.SetPassword("P@ssw0rd!#$%^&*()")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, u3.PasswordHash)
|
||||
assert.True(t, u3.CheckPassword("P@ssw0rd!#$%^&*()"))
|
||||
}
|
||||
|
||||
func TestUser_CheckPassword(t *testing.T) {
|
||||
u := &User{}
|
||||
_ = u.SetPassword("password123")
|
||||
|
||||
assert.True(t, u.CheckPassword("password123"))
|
||||
assert.False(t, u.CheckPassword("wrongpassword"))
|
||||
}
|
||||
|
||||
func TestUser_HasPendingInvite(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
user User
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "no invite token",
|
||||
user: User{InviteToken: "", InviteStatus: ""},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "expired invite",
|
||||
user: User{
|
||||
InviteToken: "token123",
|
||||
InviteExpires: timePtr(time.Now().Add(-1 * time.Hour)),
|
||||
InviteStatus: "pending",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "valid pending invite",
|
||||
user: User{
|
||||
InviteToken: "token123",
|
||||
InviteExpires: timePtr(time.Now().Add(24 * time.Hour)),
|
||||
InviteStatus: "pending",
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "already accepted invite",
|
||||
user: User{
|
||||
InviteToken: "token123",
|
||||
InviteExpires: timePtr(time.Now().Add(24 * time.Hour)),
|
||||
InviteStatus: "accepted",
|
||||
},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.user.HasPendingInvite()
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_CanAccessHost_AllowAll(t *testing.T) {
|
||||
// User with allow_all mode (blacklist) - can access everything except listed hosts
|
||||
user := User{
|
||||
Role: RoleUser,
|
||||
PermissionMode: PermissionModeAllowAll,
|
||||
PermittedHosts: []ProxyHost{
|
||||
{ID: 1}, // Blocked host
|
||||
{ID: 2}, // Blocked host
|
||||
},
|
||||
}
|
||||
|
||||
// Should NOT be able to access hosts in the blacklist
|
||||
assert.False(t, user.CanAccessHost(1))
|
||||
assert.False(t, user.CanAccessHost(2))
|
||||
|
||||
// Should be able to access other hosts
|
||||
assert.True(t, user.CanAccessHost(3))
|
||||
assert.True(t, user.CanAccessHost(100))
|
||||
}
|
||||
|
||||
func TestUser_CanAccessHost_DenyAll(t *testing.T) {
|
||||
// User with deny_all mode (whitelist) - can only access listed hosts
|
||||
user := User{
|
||||
Role: RoleUser,
|
||||
PermissionMode: PermissionModeDenyAll,
|
||||
PermittedHosts: []ProxyHost{
|
||||
{ID: 5}, // Allowed host
|
||||
{ID: 6}, // Allowed host
|
||||
},
|
||||
}
|
||||
|
||||
// Should be able to access hosts in the whitelist
|
||||
assert.True(t, user.CanAccessHost(5))
|
||||
assert.True(t, user.CanAccessHost(6))
|
||||
|
||||
// Should NOT be able to access other hosts
|
||||
assert.False(t, user.CanAccessHost(1))
|
||||
assert.False(t, user.CanAccessHost(100))
|
||||
}
|
||||
|
||||
func TestUser_CanAccessHost_AdminBypass(t *testing.T) {
|
||||
// Admin users should always have access regardless of permission mode
|
||||
adminUser := User{
|
||||
Role: RoleAdmin,
|
||||
PermissionMode: PermissionModeDenyAll,
|
||||
PermittedHosts: []ProxyHost{}, // No hosts in whitelist
|
||||
}
|
||||
|
||||
// Admin should still be able to access any host
|
||||
assert.True(t, adminUser.CanAccessHost(1))
|
||||
assert.True(t, adminUser.CanAccessHost(999))
|
||||
}
|
||||
|
||||
func TestUser_CanAccessHost_DefaultBehavior(t *testing.T) {
|
||||
// User with empty/default permission mode should behave like allow_all
|
||||
user := User{
|
||||
Role: RoleUser,
|
||||
PermissionMode: "", // Empty = default
|
||||
PermittedHosts: []ProxyHost{
|
||||
{ID: 1}, // Should be blocked
|
||||
},
|
||||
}
|
||||
|
||||
assert.False(t, user.CanAccessHost(1))
|
||||
assert.True(t, user.CanAccessHost(2))
|
||||
}
|
||||
|
||||
func TestUser_CanAccessHost_EmptyPermittedHosts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
permissionMode PermissionMode
|
||||
hostID uint
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "allow_all with no exceptions allows all",
|
||||
permissionMode: PermissionModeAllowAll,
|
||||
hostID: 1,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "deny_all with no exceptions denies all",
|
||||
permissionMode: PermissionModeDenyAll,
|
||||
hostID: 1,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
user := User{
|
||||
Role: RoleUser,
|
||||
PermissionMode: tt.permissionMode,
|
||||
PermittedHosts: []ProxyHost{},
|
||||
}
|
||||
result := user.CanAccessHost(tt.hostID)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionMode_Constants(t *testing.T) {
|
||||
assert.Equal(t, PermissionMode("allow_all"), PermissionModeAllowAll)
|
||||
assert.Equal(t, PermissionMode("deny_all"), PermissionModeDenyAll)
|
||||
}
|
||||
|
||||
func TestUserRole_Constants(t *testing.T) {
|
||||
assert.Equal(t, UserRole("admin"), RoleAdmin)
|
||||
assert.Equal(t, UserRole("user"), RoleUser)
|
||||
assert.Equal(t, UserRole("passthrough"), RolePassthrough)
|
||||
}
|
||||
|
||||
func TestUserRole_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
role UserRole
|
||||
expected bool
|
||||
}{
|
||||
{RoleAdmin, true},
|
||||
{RoleUser, true},
|
||||
{RolePassthrough, true},
|
||||
{UserRole("viewer"), false},
|
||||
{UserRole("superadmin"), false},
|
||||
{UserRole(""), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.role), func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, tt.role.IsValid())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create time pointers
|
||||
func timePtr(t time.Time) *time.Time {
|
||||
return &t
|
||||
}
|
||||
Reference in New Issue
Block a user