Renames: - platform/ → workspace-server/ (Go module path stays as "platform" for external dep compat — will update after plugin module republish) - workspace-template/ → workspace/ Removed (moved to separate repos or deleted): - PLAN.md — internal roadmap (move to private project board) - HANDOFF.md, AGENTS.md — one-time internal session docs - .claude/ — gitignored entirely (local agent config) - infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy - org-templates/molecule-dev/ → standalone template repo - .mcp-eval/ → molecule-mcp-server repo - test-results/ — ephemeral, gitignored Security scrubbing: - Cloudflare account/zone/KV IDs → placeholders - Real EC2 IPs → <EC2_IP> in all docs - CF token prefix, Neon project ID, Fly app names → redacted - Langfuse dev credentials → parameterized - Personal runner username/machine name → generic Community files: - CONTRIBUTING.md — build, test, branch conventions - CODE_OF_CONDUCT.md — Contributor Covenant 2.1 All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml, README, CLAUDE.md updated for new directory names. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
139 lines
4.4 KiB
Go
139 lines
4.4 KiB
Go
package channels
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Molecule-AI/molecule-monorepo/platform/internal/crypto"
|
|
)
|
|
|
|
// withTestEncryptionKey installs a deterministic 32-byte key for the
|
|
// duration of a test, then restores the previous state. Without this
|
|
// the tests would depend on ambient SECRETS_ENCRYPTION_KEY.
|
|
func withTestEncryptionKey(t *testing.T) {
|
|
t.Helper()
|
|
// Base64 of 32 zero bytes = "AAAA..." (44 chars). Matches the loader's
|
|
// base64 path — the raw 32-byte path requires a string that is not
|
|
// decodable as base64, which is surprisingly hard to construct.
|
|
t.Setenv("SECRETS_ENCRYPTION_KEY", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
|
|
crypto.ResetForTesting()
|
|
crypto.Init()
|
|
t.Cleanup(func() {
|
|
crypto.ResetForTesting()
|
|
})
|
|
}
|
|
|
|
func TestEncryptSensitiveFields_RoundTrip(t *testing.T) {
|
|
withTestEncryptionKey(t)
|
|
|
|
cfg := map[string]interface{}{
|
|
"bot_token": "123456:telegram-bot-token",
|
|
"chat_id": "-100999", // non-sensitive, untouched
|
|
"webhook_secret": "hmac-shared-key", // second known-sensitive field
|
|
}
|
|
|
|
if err := EncryptSensitiveFields(cfg); err != nil {
|
|
t.Fatalf("encrypt: %v", err)
|
|
}
|
|
|
|
if tok, _ := cfg["bot_token"].(string); !strings.HasPrefix(tok, ciphertextPrefix) {
|
|
t.Errorf("bot_token not encrypted: got %q", tok)
|
|
}
|
|
if sec, _ := cfg["webhook_secret"].(string); !strings.HasPrefix(sec, ciphertextPrefix) {
|
|
t.Errorf("webhook_secret not encrypted: got %q", sec)
|
|
}
|
|
if chat, _ := cfg["chat_id"].(string); chat != "-100999" {
|
|
t.Errorf("chat_id modified: got %q", chat)
|
|
}
|
|
|
|
if err := DecryptSensitiveFields(cfg); err != nil {
|
|
t.Fatalf("decrypt: %v", err)
|
|
}
|
|
|
|
if got, _ := cfg["bot_token"].(string); got != "123456:telegram-bot-token" {
|
|
t.Errorf("bot_token round-trip mismatch: got %q", got)
|
|
}
|
|
if got, _ := cfg["webhook_secret"].(string); got != "hmac-shared-key" {
|
|
t.Errorf("webhook_secret round-trip mismatch: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEncryptSensitiveFields_Idempotent(t *testing.T) {
|
|
withTestEncryptionKey(t)
|
|
|
|
cfg := map[string]interface{}{"bot_token": "abc"}
|
|
if err := EncryptSensitiveFields(cfg); err != nil {
|
|
t.Fatalf("first encrypt: %v", err)
|
|
}
|
|
first, _ := cfg["bot_token"].(string)
|
|
|
|
if err := EncryptSensitiveFields(cfg); err != nil {
|
|
t.Fatalf("second encrypt: %v", err)
|
|
}
|
|
second, _ := cfg["bot_token"].(string)
|
|
|
|
if first != second {
|
|
t.Errorf("idempotent encrypt should not re-wrap: first=%q second=%q", first, second)
|
|
}
|
|
}
|
|
|
|
func TestDecryptSensitiveFields_LegacyPlaintextPassesThrough(t *testing.T) {
|
|
// Legacy row predates #319 — no ciphertext prefix.
|
|
withTestEncryptionKey(t)
|
|
|
|
cfg := map[string]interface{}{"bot_token": "legacy-plaintext-value"}
|
|
if err := DecryptSensitiveFields(cfg); err != nil {
|
|
t.Fatalf("decrypt: %v", err)
|
|
}
|
|
if got, _ := cfg["bot_token"].(string); got != "legacy-plaintext-value" {
|
|
t.Errorf("legacy plaintext was mangled: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEncryptSensitiveFields_DevFallback_NoKey(t *testing.T) {
|
|
// No key set — dev behaviour matches workspace_secrets: store plaintext.
|
|
t.Setenv("SECRETS_ENCRYPTION_KEY", "")
|
|
crypto.ResetForTesting()
|
|
crypto.Init()
|
|
t.Cleanup(crypto.ResetForTesting)
|
|
|
|
cfg := map[string]interface{}{"bot_token": "dev-token"}
|
|
if err := EncryptSensitiveFields(cfg); err != nil {
|
|
t.Fatalf("encrypt: %v", err)
|
|
}
|
|
if got, _ := cfg["bot_token"].(string); got != "dev-token" {
|
|
t.Errorf("dev fallback should leave plaintext: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEncryptSensitiveFields_SkipsEmptyAndNonString(t *testing.T) {
|
|
withTestEncryptionKey(t)
|
|
|
|
cfg := map[string]interface{}{
|
|
"bot_token": "", // empty
|
|
"webhook_secret": 12345, // non-string
|
|
"unrelated": "ignore", // not in sensitiveFields
|
|
}
|
|
if err := EncryptSensitiveFields(cfg); err != nil {
|
|
t.Fatalf("encrypt: %v", err)
|
|
}
|
|
if got, _ := cfg["bot_token"].(string); got != "" {
|
|
t.Errorf("empty bot_token should stay empty: got %q", got)
|
|
}
|
|
if got, _ := cfg["webhook_secret"].(int); got != 12345 {
|
|
t.Errorf("non-string webhook_secret should be untouched: got %v", cfg["webhook_secret"])
|
|
}
|
|
if got, _ := cfg["unrelated"].(string); got != "ignore" {
|
|
t.Errorf("unrelated field should be untouched: got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestEncryptSensitiveFields_NilConfig(t *testing.T) {
|
|
if err := EncryptSensitiveFields(nil); err != nil {
|
|
t.Errorf("nil config: expected no error, got %v", err)
|
|
}
|
|
if err := DecryptSensitiveFields(nil); err != nil {
|
|
t.Errorf("nil config: expected no error, got %v", err)
|
|
}
|
|
}
|