iop/apps/control-plane/cmd/control-plane/credential_store_test.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

566 lines
17 KiB
Go

package main
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest"
"go.uber.org/zap/zaptest/observer"
"iop/apps/control-plane/internal/credentialseal"
)
// writeKeyManifest writes a valid 0600 key manifest with one 32-byte key
// (id "primary", version 1) and returns its path. The material is deterministic
// so a reopened keyring holds identical bytes.
func writeKeyManifest(t *testing.T, fill byte) string {
t.Helper()
path := filepath.Join(t.TempDir(), "keyring.yaml")
material := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{fill}, 32))
body := "keys:\n - id: \"primary\"\n version: 1\n material: \"" + material + "\"\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
return path
}
func encryptionConfig(keyFile string) credentialseal.FileConfig {
return credentialseal.FileConfig{KeyFile: keyFile, ActiveKeyID: "primary", ActiveKeyVersion: 1}
}
func TestComposeCredentialRuntimeDatabaseOnly(t *testing.T) {
cfg := defaultConfig()
cfg.Database.URL = ""
cred, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
if err != nil {
t.Fatalf("compose: %v", err)
}
if cred.store != nil {
t.Fatal("empty database URL must leave the store unopened")
}
if cred.service != nil {
t.Fatal("no service without encryption")
}
}
func TestComposeCredentialRuntimeSecretDisabledMode(t *testing.T) {
cfg := defaultConfig()
cfg.Database.URL = filepath.Join(t.TempDir(), "disabled.db")
cred, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
if err != nil {
t.Fatalf("compose: %v", err)
}
if cred.store == nil {
t.Fatal("store must open for a configured database")
}
t.Cleanup(func() { _ = cred.store.Close() })
if cred.service != nil {
t.Fatal("secret-disabled mode must not inject a credential service")
}
}
func TestComposeCredentialRuntimeConfiguredInjectsService(t *testing.T) {
cfg := defaultConfig()
cfg.Database.URL = filepath.Join(t.TempDir(), "configured.db")
cfg.CredentialEncryption = encryptionConfig(writeKeyManifest(t, 0x3C))
cred, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
if err != nil {
t.Fatalf("compose: %v", err)
}
if cred.store == nil {
t.Fatal("store must open when encryption is configured")
}
t.Cleanup(func() { _ = cred.store.Close() })
if cred.service == nil {
t.Fatal("configured encryption must inject a credential service")
}
}
func TestComposeCredentialRuntimePartialConfigFailsBeforeStore(t *testing.T) {
for name, enc := range map[string]credentialseal.FileConfig{
"only-file": {KeyFile: "/run/secrets/x"},
"only-id": {ActiveKeyID: "primary"},
"only-version": {ActiveKeyVersion: 1},
"file-id": {KeyFile: "/run/secrets/x", ActiveKeyID: "primary"},
} {
t.Run(name, func(t *testing.T) {
cfg := defaultConfig()
dbPath := filepath.Join(t.TempDir(), "partial.db")
cfg.Database.URL = dbPath
cfg.CredentialEncryption = enc
_, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
if err == nil {
t.Fatal("expected partial encryption config to fail")
}
if !strings.Contains(err.Error(), "credential encryption") {
t.Fatalf("expected credential encryption error, got: %v", err)
}
// Fail-closed before opening the store: no database file is created.
if _, statErr := os.Stat(dbPath); statErr == nil {
t.Fatal("store must not open when encryption config is partial")
}
})
}
}
func TestComposeCredentialRuntimeInvalidManifestVariants(t *testing.T) {
t.Run("missing file", func(t *testing.T) {
cfg := defaultConfig()
cfg.Database.URL = filepath.Join(t.TempDir(), "x.db")
cfg.CredentialEncryption = encryptionConfig(filepath.Join(t.TempDir(), "absent.yaml"))
if _, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t)); err == nil {
t.Fatal("expected missing key file to fail")
}
})
t.Run("insecure permissions", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "perm.yaml")
material := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x11}, 32))
body := "keys:\n - id: \"primary\"\n version: 1\n material: \"" + material + "\"\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatalf("write manifest: %v", err)
}
cfg := defaultConfig()
cfg.Database.URL = filepath.Join(t.TempDir(), "x.db")
cfg.CredentialEncryption = encryptionConfig(path)
if _, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t)); err == nil {
t.Fatal("expected group-readable key file to fail")
}
})
t.Run("malformed manifest", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.yaml")
if err := os.WriteFile(path, []byte("keys: [broken"), 0o600); err != nil {
t.Fatalf("write manifest: %v", err)
}
cfg := defaultConfig()
cfg.Database.URL = filepath.Join(t.TempDir(), "x.db")
cfg.CredentialEncryption = encryptionConfig(path)
if _, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t)); err == nil {
t.Fatal("expected malformed manifest to fail")
}
})
t.Run("unknown active key", func(t *testing.T) {
cfg := defaultConfig()
cfg.Database.URL = filepath.Join(t.TempDir(), "x.db")
cfg.CredentialEncryption = credentialseal.FileConfig{KeyFile: writeKeyManifest(t, 0x22), ActiveKeyID: "primary", ActiveKeyVersion: 9}
if _, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t)); err == nil {
t.Fatal("expected unknown active key to fail")
}
})
}
func TestComposeCredentialRuntimeRestartReopen(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "restart.db")
keyFile := writeKeyManifest(t, 0x4D)
cfg := defaultConfig()
cfg.Database.URL = dbPath
cfg.CredentialEncryption = encryptionConfig(keyFile)
first, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
if err != nil {
t.Fatalf("first compose: %v", err)
}
if err := first.store.Close(); err != nil {
t.Fatalf("close first store: %v", err)
}
second, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
if err != nil {
t.Fatalf("second compose after restart: %v", err)
}
t.Cleanup(func() { _ = second.store.Close() })
if second.store == nil || second.service == nil {
t.Fatal("restart must reopen store and reinject service")
}
}
func TestRunWithEncryptionConfigStartsAndLogsNoSecret(t *testing.T) {
t.Setenv("IOP_DATABASE_URL", "")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
core, observed := observer.New(zap.InfoLevel)
logger := zap.New(core)
keyFile := writeKeyManifest(t, 0x5E)
material := readManifestMaterial(t, keyFile)
cfg := defaultConfig()
cfg.Database.URL = filepath.Join(t.TempDir(), "run-enc.db")
cfg.CredentialEncryption = encryptionConfig(keyFile)
cfg.Server.Listen = "127.0.0.1:0"
cfg.Server.WireListen = "127.0.0.1:0"
cfg.Server.EdgeWireListen = "127.0.0.1:0"
errCh := make(chan error, 1)
go func() { errCh <- run(ctx, cfg, logger) }()
select {
case err := <-errCh:
if err != nil && ctx.Err() == nil {
t.Fatalf("run with encryption: %v", err)
}
case <-time.After(1 * time.Second):
cancel()
<-errCh
}
var sawReady, encEnabled bool
for _, entry := range observed.All() {
if entry.Message == "control-plane credential store ready" {
sawReady = true
for _, f := range entry.Context {
if f.Key == "secret_encryption" && f.Integer == 1 {
encEnabled = true
}
}
}
// No log entry may carry the raw key material.
full := entry.Message
for _, f := range entry.Context {
full += fmt.Sprintf(" %s=%v", f.Key, f.Interface) + f.String
}
if strings.Contains(full, material) {
t.Fatalf("log leaked key material: %q", entry.Message)
}
}
if !sawReady {
t.Fatal("expected credential store ready log")
}
if !encEnabled {
t.Fatal("expected secret_encryption=true in ready log")
}
}
func TestRunWithPartialEncryptionConfigDoesNotStartListener(t *testing.T) {
t.Setenv("IOP_DATABASE_URL", "")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
core, observed := observer.New(zap.InfoLevel)
logger := zap.New(core)
cfg := defaultConfig()
cfg.Database.URL = filepath.Join(t.TempDir(), "partial-run.db")
cfg.CredentialEncryption = credentialseal.FileConfig{ActiveKeyID: "primary", ActiveKeyVersion: 1} // no key_file
cfg.Server.Listen = "127.0.0.1:0"
cfg.Server.WireListen = "127.0.0.1:0"
cfg.Server.EdgeWireListen = "127.0.0.1:0"
errCh := make(chan error, 1)
go func() { errCh <- run(ctx, cfg, logger) }()
select {
case err := <-errCh:
if err == nil {
t.Fatal("expected run to fail on partial encryption config")
}
if !strings.Contains(err.Error(), "credential encryption") {
t.Fatalf("expected credential encryption error, got: %v", err)
}
case <-time.After(2 * time.Second):
cancel()
t.Fatal("run did not fail within timeout")
}
for _, entry := range observed.All() {
if strings.Contains(entry.Message, "http endpoint listening") {
t.Fatal("http listener must not start when encryption config is partial")
}
}
}
func readManifestMaterial(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read manifest: %v", err)
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "material:") {
return strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "material:")), "\"")
}
}
t.Fatal("manifest material not found")
return ""
}
func TestRunAllowsUnconfiguredDatabase(t *testing.T) {
t.Setenv("IOP_DATABASE_URL", "")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
core, observed := observer.New(zap.InfoLevel)
logger := zap.New(core)
cfg := defaultConfig()
cfg.Database.URL = ""
cfg.Server.Listen = "127.0.0.1:0"
cfg.Server.WireListen = "127.0.0.1:0"
cfg.Server.EdgeWireListen = "127.0.0.1:0"
errCh := make(chan error, 1)
go func() {
errCh <- run(ctx, cfg, logger)
}()
// Give the server a moment to start (or fail).
select {
case err := <-errCh:
// Server exited; check it did not fail on empty database.
if err != nil && !strings.Contains(err.Error(), "use of closed") && !strings.Contains(err.Error(), "server closed") {
// Accept context cancellation or shutdown errors.
if ctx.Err() == nil {
t.Fatalf("run with empty database: %v", err)
}
}
case <-time.After(1 * time.Second):
// Server is still running; cancel context to shut it down.
cancel()
<-errCh
}
// No log entry should reference a database connection attempt.
for _, entry := range observed.All() {
if strings.Contains(entry.Message, "credential-store") || strings.Contains(entry.Message, "credentialstore") {
t.Fatalf("unexpected credential store log: %q", entry.Message)
}
}
}
func TestRunRejectsInvalidDatabaseURL(t *testing.T) {
t.Setenv("IOP_DATABASE_URL", "")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
core, observed := observer.New(zap.InfoLevel)
logger := zap.New(core)
cfg := defaultConfig()
// Use an unsupported scheme that dialectFromURL rejects immediately.
cfg.Database.URL = "memdb://invalid-host:99999/test"
cfg.Server.Listen = "127.0.0.1:0"
cfg.Server.WireListen = "127.0.0.1:0"
cfg.Server.EdgeWireListen = "127.0.0.1:0"
errCh := make(chan error, 1)
go func() {
errCh <- run(ctx, cfg, logger)
}()
select {
case err := <-errCh:
if err == nil {
t.Fatal("expected run to fail with invalid database URL")
}
if !strings.Contains(err.Error(), "credential store") && !strings.Contains(err.Error(), "credentialstore") {
t.Fatalf("expected credential store error, got: %v", err)
}
case <-time.After(2 * time.Second):
cancel()
t.Fatal("run did not fail within timeout")
}
// Verify no credential store log contains the raw URL.
for _, entry := range observed.All() {
if strings.Contains(entry.Message, "memdb://") {
t.Fatalf("raw invalid URL leaked into log: %q", entry.Message)
}
}
}
func TestRunWithSQLiteFileSucceeds(t *testing.T) {
t.Setenv("IOP_DATABASE_URL", "")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
core, observed := observer.New(zap.InfoLevel)
logger := zap.New(core)
tmpDir := t.TempDir()
dbFile := filepath.Join(tmpDir, "test.db")
cfg := defaultConfig()
cfg.Database.URL = dbFile
cfg.Server.Listen = "127.0.0.1:0"
cfg.Server.WireListen = "127.0.0.1:0"
cfg.Server.EdgeWireListen = "127.0.0.1:0"
errCh := make(chan error, 1)
go func() {
errCh <- run(ctx, cfg, logger)
}()
select {
case err := <-errCh:
if err != nil && ctx.Err() == nil {
t.Fatalf("run with SQLite: %v", err)
}
case <-time.After(1 * time.Second):
cancel()
<-errCh
}
// Verify DB file was created.
if _, err := os.Stat(dbFile); os.IsNotExist(err) {
t.Fatal("SQLite database file was not created")
}
// Verify schema was migrated.
for _, entry := range observed.All() {
if strings.Contains(entry.Message, "memdb://") {
t.Fatalf("unexpected URL in log: %q", entry.Message)
}
}
}
func TestDatabaseLogFieldsDoNotExposeCredentialStoreURL(t *testing.T) {
// Verify the existing databaseLogFields helper does not leak credentials
// when called with a credential store URL.
fields := databaseLogFields("postgres://admin:supersecret@db:5432/iop-creds?sslmode=require")
for _, field := range fields {
if strings.Contains(fmt.Sprint(field), "supersecret") {
t.Fatalf("credential leaked into log field: %v", field)
}
}
}
func TestRunWithUnreachablePostgresFails(t *testing.T) {
t.Setenv("IOP_DATABASE_URL", "")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
core, observed := observer.New(zap.InfoLevel)
logger := zap.New(core)
cfg := defaultConfig()
// Use a port that is guaranteed to be unreachable.
cfg.Database.URL = "postgres://localhost:1/noexist"
cfg.Server.Listen = "127.0.0.1:0"
cfg.Server.WireListen = "127.0.0.1:0"
cfg.Server.EdgeWireListen = "127.0.0.1:0"
errCh := make(chan error, 1)
go func() {
errCh <- run(ctx, cfg, logger)
}()
select {
case err := <-errCh:
if err == nil {
t.Fatal("expected run to fail with unreachable postgres")
}
if !strings.Contains(err.Error(), "credential store") && !strings.Contains(err.Error(), "credentialstore") {
t.Fatalf("expected credential store error, got: %v", err)
}
case <-time.After(2 * time.Second):
cancel()
t.Fatal("run did not fail within timeout")
}
// Verify no credential store log leaked the URL.
for _, entry := range observed.All() {
msg := entry.Message
for _, f := range entry.Context {
msg += fmt.Sprintf(" %s=%v", f.Key, f.Interface)
}
if strings.Contains(msg, "localhost:1/noexist") {
t.Fatalf("raw database URL leaked into log: %q", entry.Message)
}
}
}
// helper: find a free port for tests.
func TestComposeCredentialRuntimeEncryptionRequiresDatabase(t *testing.T) {
for _, dbURL := range []string{"", " ", " \t "} {
name := "empty"
if dbURL == " " {
name = "whitespace"
} else if dbURL == " \t " {
name = "spaces-and-tabs"
}
t.Run(name, func(t *testing.T) {
cfg := defaultConfig()
cfg.Database.URL = dbURL
cfg.CredentialEncryption = encryptionConfig(writeKeyManifest(t, 0x6F))
_, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
if err == nil {
t.Fatal("expected encryption without database to fail")
}
if !strings.Contains(err.Error(), "credential encryption requires database.url") {
t.Fatalf("expected credential encryption requires database.url error, got: %v", err)
}
})
}
}
func TestRunWithEncryptionAndNoDatabaseDoesNotStartListener(t *testing.T) {
t.Setenv("IOP_DATABASE_URL", "")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
core, observed := observer.New(zap.InfoLevel)
logger := zap.New(core)
keyFile := writeKeyManifest(t, 0x7A)
cfg := defaultConfig()
cfg.Database.URL = ""
cfg.CredentialEncryption = encryptionConfig(keyFile)
cfg.Server.Listen = "127.0.0.1:0"
cfg.Server.WireListen = "127.0.0.1:0"
cfg.Server.EdgeWireListen = "127.0.0.1:0"
errCh := make(chan error, 1)
go func() { errCh <- run(ctx, cfg, logger) }()
select {
case err := <-errCh:
if err == nil {
t.Fatal("expected run to fail with encryption and no database")
}
if !strings.Contains(err.Error(), "credential encryption requires database.url") {
t.Fatalf("expected credential encryption requires database.url error, got: %v", err)
}
case <-time.After(2 * time.Second):
cancel()
t.Fatal("run did not fail within timeout")
}
// No HTTP, client wire, or edge wire listening log may be emitted.
for _, entry := range observed.All() {
if strings.Contains(entry.Message, "http endpoint listening") {
t.Fatal("http listener must not start when encryption has no database")
}
if entry.Message == "starting client wire WS server" {
t.Fatal("client wire listener must not start when encryption has no database")
}
if entry.Message == "starting edge wire TCP server" {
t.Fatal("edge wire listener must not start when encryption has no database")
}
}
}
func freePort(t *testing.T) string {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("find free port: %v", err)
}
defer l.Close()
return l.Addr().String()
}