diff --git a/src/core/config/config_test.go b/src/core/config/config_test.go new file mode 100644 index 0000000..e5635fe --- /dev/null +++ b/src/core/config/config_test.go @@ -0,0 +1,132 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +const testdataDir = "testdata" + +func testdataPath(name string) string { + return filepath.Join(testdataDir, name) +} + +// --- Config.Load tests --- + +func TestLoad_ValidConfig(t *testing.T) { + cfg, err := Load(testdataPath("valid-config.yaml")) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if cfg.Server.Host != "0.0.0.0" { + t.Errorf("expected Server.Host '0.0.0.0', got %q", cfg.Server.Host) + } + if cfg.Server.Port != 8080 { + t.Errorf("expected Server.Port 8080, got %d", cfg.Server.Port) + } + if cfg.Admin.SecretToken != "test-admin-token-123" { + t.Errorf("expected Admin.SecretToken 'test-admin-token-123', got %q", cfg.Admin.SecretToken) + } + if cfg.Database.Type != "sqlite" { + t.Errorf("expected Database.Type 'sqlite', got %q", cfg.Database.Type) + } + if cfg.Database.Path != "/tmp/nextwks-test.db" { + t.Errorf("expected Database.Path '/tmp/nextwks-test.db', got %q", cfg.Database.Path) + } + if cfg.Authelia.Host != "http://127.0.0.1:9091" { + t.Errorf("expected Authelia.Host 'http://127.0.0.1:9091', got %q", cfg.Authelia.Host) + } + if cfg.Session.Secret != "test-session-secret" { + t.Errorf("expected Session.Secret 'test-session-secret', got %q", cfg.Session.Secret) + } + if cfg.Session.ExpiryMinutes != 60 { + t.Errorf("expected Session.ExpiryMinutes 60, got %d", cfg.Session.ExpiryMinutes) + } + if cfg.SMTP.Host != "mail.example.com" { + t.Errorf("expected SMTP.Host 'mail.example.com', got %q", cfg.SMTP.Host) + } + if cfg.SMTP.Port != 587 { + t.Errorf("expected SMTP.Port 587, got %d", cfg.SMTP.Port) + } + if cfg.SMTP.From != "noreply@example.com" { + t.Errorf("expected SMTP.From 'noreply@example.com', got %q", cfg.SMTP.From) + } +} + +func TestLoad_MissingFile(t *testing.T) { + _, err := Load(testdataPath("nonexistent-file.yaml")) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestLoad_InvalidYAML(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "invalid.yaml") + if err := os.WriteFile(tmpFile, []byte("invalid: yaml: \n bad: ["), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + _, err := Load(tmpFile) + if err == nil { + t.Fatal("expected error for invalid YAML, got nil") + } +} + +func TestLoad_EmptyFile(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "empty.yaml") + if err := os.WriteFile(tmpFile, []byte(""), 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + cfg, err := Load(tmpFile) + if err != nil { + t.Fatalf("expected no error for empty file, got: %v", err) + } + + // Empty file should yield zero-value config + if cfg.Server.Port != 0 { + t.Errorf("expected zero-value Port, got %d", cfg.Server.Port) + } +} + +// --- Config.AutheliaSessionSecret tests --- + +func TestAutheliaSessionSecret_Valid(t *testing.T) { + secret, err := AutheliaSessionSecret(testdataPath("valid-authelia-config.yaml")) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if secret != "authelia-test-session-secret" { + t.Errorf("expected secret 'authelia-test-session-secret', got %q", secret) + } +} + +func TestAutheliaSessionSecret_MissingFile(t *testing.T) { + _, err := AutheliaSessionSecret(testdataPath("nonexistent-authelia-config.yaml")) + if err == nil { + t.Fatal("expected error for missing file, got nil") + } +} + +func TestAutheliaSessionSecret_NoSecretField(t *testing.T) { + _, err := AutheliaSessionSecret(testdataPath("no-session-authelia-config.yaml")) + if err == nil { + t.Fatal("expected error when session.secret is missing, got nil") + } +} + +func TestAutheliaSessionSecret_EmptySecret(t *testing.T) { + tmpFile := filepath.Join(t.TempDir(), "authelia-empty-secret.yaml") + content := []byte("session:\n name: test\n secret: \"\"\n") + if err := os.WriteFile(tmpFile, content, 0644); err != nil { + t.Fatalf("failed to write temp file: %v", err) + } + + _, err := AutheliaSessionSecret(tmpFile) + if err == nil { + t.Fatal("expected error for empty session.secret, got nil") + } +} diff --git a/src/core/config/testdata/no-session-authelia-config.yaml b/src/core/config/testdata/no-session-authelia-config.yaml new file mode 100644 index 0000000..dd3562b --- /dev/null +++ b/src/core/config/testdata/no-session-authelia-config.yaml @@ -0,0 +1,15 @@ +host: 0.0.0.0 +port: 9091 + +log: + level: debug + +jwt_secret: test-jwt-secret + +storage: + local: + path: /opt/authelia/data/db.sqlite + +authentication_backend: + file: + path: /opt/authelia/data/users_database.yml diff --git a/src/core/config/testdata/valid-authelia-config.yaml b/src/core/config/testdata/valid-authelia-config.yaml new file mode 100644 index 0000000..20d9f69 --- /dev/null +++ b/src/core/config/testdata/valid-authelia-config.yaml @@ -0,0 +1,21 @@ +host: 0.0.0.0 +port: 9091 + +log: + level: debug + +jwt_secret: test-jwt-secret + +session: + name: authelia_session + secret: authelia-test-session-secret + expiration: 1h + inactivity: 5m + +storage: + local: + path: /opt/authelia/data/db.sqlite + +authentication_backend: + file: + path: /opt/authelia/data/users_database.yml diff --git a/src/core/config/testdata/valid-config.yaml b/src/core/config/testdata/valid-config.yaml new file mode 100644 index 0000000..6e06e2f --- /dev/null +++ b/src/core/config/testdata/valid-config.yaml @@ -0,0 +1,26 @@ +server: + host: "0.0.0.0" + port: 8080 + +admin: + secret_token: "test-admin-token-123" + +database: + type: "sqlite" + path: "/tmp/nextwks-test.db" + +authelia: + host: "http://127.0.0.1:9091" + config_path: "/opt/authelia/config/configuration.yml" + users_db_path: "/opt/authelia/data/users_database.yml" + +smtp: + host: "mail.example.com" + port: 587 + username: "test@example.com" + password: "test-password" + from: "noreply@example.com" + +session: + secret: "test-session-secret" + expiry_minutes: 60 diff --git a/src/core/db/db_test.go b/src/core/db/db_test.go new file mode 100644 index 0000000..272b5c2 --- /dev/null +++ b/src/core/db/db_test.go @@ -0,0 +1,176 @@ +package db + +import ( + "database/sql" + "os" + "path/filepath" + "testing" +) + +func TestInitialize_CreatesDirectory(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "subdir", "test.db") + + db, err := Initialize(dbPath) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + defer db.Close() + + // Verify directory was created + if _, err := os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) { + t.Fatal("expected directory to be created") + } + + // Verify database file was created + if _, err := os.Stat(dbPath); os.IsNotExist(err) { + t.Fatal("expected database file to be created") + } +} + +func TestInitialize_OpensConnection(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + db, err := Initialize(dbPath) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + defer db.Close() + + // Verify connection is alive + if err := db.DB.Ping(); err != nil { + t.Fatalf("expected ping to succeed, got: %v", err) + } +} + +func TestInitialize_ExistingFile(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "existing.db") + + // Create database once + db1, err := Initialize(dbPath) + if err != nil { + t.Fatalf("first init failed: %v", err) + } + db1.Close() + + // Re-open existing database + db2, err := Initialize(dbPath) + if err != nil { + t.Fatalf("second init failed: %v", err) + } + defer db2.Close() + + if err := db2.DB.Ping(); err != nil { + t.Fatalf("expected ping to succeed, got: %v", err) + } +} + +func TestMigrate_CreatesTables(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "migrate-test.db") + + database, err := Initialize(dbPath) + if err != nil { + t.Fatalf("init failed: %v", err) + } + defer database.Close() + + if err := database.Migrate(); err != nil { + t.Fatalf("migrate failed: %v", err) + } + + // Verify tables exist + expectedTables := []string{"sessions", "audit_logs"} + for _, table := range expectedTables { + var count int + row := database.DB.QueryRow( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?", + table, + ) + if err := row.Scan(&count); err != nil { + t.Fatalf("failed to check table %s: %v", table, err) + } + if count == 0 { + t.Errorf("expected table %s to exist", table) + } + } +} + +func TestMigrate_Idempotent(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "idempotent-test.db") + + database, err := Initialize(dbPath) + if err != nil { + t.Fatalf("init failed: %v", err) + } + defer database.Close() + + // Run migrations twice + if err := database.Migrate(); err != nil { + t.Fatalf("first migrate failed: %v", err) + } + if err := database.Migrate(); err != nil { + t.Fatalf("second migrate should succeed (idempotent), got: %v", err) + } +} + +func TestMigrate_TableSchemas(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "schema-test.db") + + database, err := Initialize(dbPath) + if err != nil { + t.Fatalf("init failed: %v", err) + } + defer database.Close() + database.Migrate() + + // Verify sessions table columns + rows, err := database.DB.Query("PRAGMA table_info(sessions)") + if err != nil { + t.Fatalf("failed to get sessions schema: %v", err) + } + defer rows.Close() + + columns := map[string]bool{} + for rows.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dflt sql.NullString + if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil { + t.Fatalf("failed to scan column: %v", err) + } + columns[name] = true + _ = ctype + } + + expectedCols := []string{"id", "user_id", "token_hash", "created_at", "expires_at"} + for _, col := range expectedCols { + if !columns[col] { + t.Errorf("expected column %q in sessions table", col) + } + } +} + +func TestClose(t *testing.T) { + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "close-test.db") + + database, err := Initialize(dbPath) + if err != nil { + t.Fatalf("init failed: %v", err) + } + + if err := database.Close(); err != nil { + t.Fatalf("close failed: %v", err) + } + + // Ping should fail after close + if err := database.DB.Ping(); err == nil { + t.Fatal("expected ping to fail after close") + } +}