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

534 lines
16 KiB
Go

package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"testing"
"github.com/spf13/pflag"
"iop/apps/control-plane/internal/credentialstore"
_ "modernc.org/sqlite"
)
// newBootstrapTestStore opens a fresh SQLite store for bootstrap tests,
// returning the file path so the command can load config pointing at it.
func newBootstrapTestStore(t *testing.T) (string, *credentialstore.Store) {
t.Helper()
ctx := context.Background()
tmpDir := t.TempDir()
dbFile := filepath.Join(tmpDir, "bootstrap.db")
store, err := credentialstore.Open(ctx, dbFile)
if err != nil {
t.Fatalf("open test store: %v", err)
}
t.Cleanup(func() { _ = store.Close() })
return dbFile, store
}
func TestPrincipalBootstrapEmitsTokenOnce(t *testing.T) {
dbFile, _ := newBootstrapTestStore(t)
cfgPath := writeBootstrapConfig(t, dbFile)
var stdout, stderr bytes.Buffer
cmd := rootCmd()
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--config", cfgPath, "principal", "bootstrap", "--alias", "edge-prod"})
if err := cmd.Execute(); err != nil {
t.Fatalf("bootstrap: %v\nstderr: %s", err, stderr.String())
}
// Find the raw token in stdout (might be preceded by whitespace or other output).
var rawToken string
for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
line = strings.TrimSpace(line)
if len(line) == 64 {
if _, err := hex.DecodeString(line); err == nil {
rawToken = line
break
}
}
}
if rawToken == "" {
t.Fatalf("expected raw token (64 hex chars) on stdout, got: %q", stdout.String())
}
// stderr must not contain the raw token.
if strings.Contains(stderr.String(), rawToken) {
t.Fatalf("raw token leaked to stderr: %s", stderr.String())
}
// stdout must contain the token exactly once.
if strings.Count(stdout.String(), rawToken) != 1 {
t.Fatalf("raw token emitted %d times on stdout; expected 1", strings.Count(stdout.String(), rawToken))
}
}
func TestPrincipalBootstrapRefusesExistingPrincipal(t *testing.T) {
dbFile, store := newBootstrapTestStore(t)
// Pre-create a principal so the bootstrap command must refuse.
_, err := store.CreatePrincipalWithToken(context.Background(), credentialstore.CreatePrincipalInput{Alias: "existing"})
if err != nil {
t.Fatalf("create existing principal: %v", err)
}
cfgPath := writeBootstrapConfig(t, dbFile)
var stdout, stderr bytes.Buffer
cmd := rootCmd()
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--config", cfgPath, "principal", "bootstrap", "--alias", "edge-new"})
err = cmd.Execute()
if err == nil {
t.Fatal("expected bootstrap to refuse when principal already exists")
}
if !strings.Contains(err.Error(), "already exists") {
t.Fatalf("expected 'already exists' error, got: %v", err)
}
// stdout must not contain a raw token (64 hex chars).
for _, line := range strings.Split(strings.TrimSpace(stdout.String()), "\n") {
line = strings.TrimSpace(line)
if len(line) == 64 {
if _, err := hex.DecodeString(line); err == nil {
t.Fatalf("stdout must not contain raw token when bootstrap refuses: %q", line)
}
}
}
}
func TestPrincipalBootstrapPersistsAcrossReopen(t *testing.T) {
dbFile, _ := newBootstrapTestStore(t)
cfgPath := writeBootstrapConfig(t, dbFile)
var stdout bytes.Buffer
cmd := rootCmd()
cmd.SetOut(&stdout)
cmd.SetErr(os.Stderr)
cmd.SetArgs([]string{"--config", cfgPath, "principal", "bootstrap", "--alias", "persist"})
if err := cmd.Execute(); err != nil {
t.Fatalf("bootstrap: %v", err)
}
rawToken := strings.TrimSpace(stdout.String())
// Reopen the store and verify the principal persists.
store2, err := credentialstore.Open(context.Background(), dbFile)
if err != nil {
t.Fatalf("reopen store: %v", err)
}
defer store2.Close()
principals, err := store2.ListPrincipals(context.Background())
if err != nil {
t.Fatalf("list principals after reopen: %v", err)
}
if len(principals) != 1 {
t.Fatalf("principal count after reopen: got %d want 1", len(principals))
}
if principals[0].Principal.Alias != "persist" {
t.Fatalf("alias after reopen: got %q want %q", principals[0].Principal.Alias, "persist")
}
// Verify the raw token can authenticate via digest lookup.
sum := sha256.Sum256([]byte(rawToken))
digest := hex.EncodeToString(sum[:])
_, token, err := store2.LookupTokenByDigest(context.Background(), digest)
if err != nil {
t.Fatalf("lookup by digest after reopen: %v", err)
}
if token == nil || token.Status != credentialstore.StatusActive {
t.Fatalf("token must persist as active after reopen: %+v", token)
}
}
func TestBootstrapLogsNeverContainToken(t *testing.T) {
dbFile, _ := newBootstrapTestStore(t)
cfgPath := writeBootstrapConfig(t, dbFile)
// The bootstrap command writes the raw token directly to stdout via fmt.Fprintln.
// It does not use zap for bootstrap logging; verify the token is isolated from
// any logging path by checking stdout and stderr independently.
var stdout, stderr bytes.Buffer
cmd := rootCmd()
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--config", cfgPath, "principal", "bootstrap", "--alias", "log-test"})
if err := cmd.Execute(); err != nil {
t.Fatalf("bootstrap: %v", err)
}
rawToken := strings.TrimSpace(stdout.String())
// The command does not use zap for bootstrap logging; it writes directly
// to stdout. Verify the raw token does not appear in stdout beyond the
// single emit line, and does not appear in stderr at all.
stdoutContent := stdout.String()
if strings.Count(stdoutContent, rawToken) != 1 {
t.Fatalf("raw token appeared %d times in stdout; expected exactly 1", strings.Count(stdoutContent, rawToken))
}
if strings.Contains(stderr.String(), rawToken) {
t.Fatalf("raw token leaked to stderr: %q", stderr.String())
}
// Verify the raw token does not appear in any DB column.
store, err := credentialstore.Open(context.Background(), dbFile)
if err != nil {
t.Fatalf("open store for verification: %v", err)
}
defer store.Close()
var allText string
rows, err := store.DB().QueryContext(context.Background(), `SELECT * FROM principals`)
if err != nil {
t.Fatalf("scan principals: %v", err)
}
defer rows.Close()
cols, _ := rows.Columns()
for rows.Next() {
vals := make([]interface{}, len(cols))
ptrs := make([]interface{}, len(cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows.Scan(ptrs...); err != nil {
t.Fatalf("scan principal row: %v", err)
}
for _, v := range vals {
if s, ok := v.(string); ok {
allText += s
}
}
}
rows2, err := store.DB().QueryContext(context.Background(), `SELECT * FROM tokens`)
if err != nil {
t.Fatalf("scan tokens: %v", err)
}
defer rows2.Close()
cols2, _ := rows2.Columns()
for rows2.Next() {
vals := make([]interface{}, len(cols2))
ptrs := make([]interface{}, len(cols2))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rows2.Scan(ptrs...); err != nil {
t.Fatalf("scan token row: %v", err)
}
for _, v := range vals {
if s, ok := v.(string); ok {
allText += s
}
}
}
if strings.Contains(allText, rawToken) {
t.Fatal("raw token found in database columns")
}
// Verify digest is stored (not raw token).
var digest string
store.DB().QueryRowContext(context.Background(), `SELECT digest FROM tokens`).Scan(&digest)
if digest == rawToken {
t.Fatal("digest must not equal raw token")
}
expectedDigest := sha256Hex([]byte(rawToken))
if digest != expectedDigest {
t.Fatalf("stored digest mismatch: got %q want %q", digest, expectedDigest)
}
}
func TestBootstrapRequiresAlias(t *testing.T) {
dbFile, _ := newBootstrapTestStore(t)
cfgPath := writeBootstrapConfig(t, dbFile)
var stderr bytes.Buffer
cmd := rootCmd()
cmd.SetOut(os.Stderr)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--config", cfgPath, "principal", "bootstrap"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when --alias is missing")
}
if !strings.Contains(err.Error(), "alias is required") {
t.Fatalf("expected 'alias is required' error, got: %v", err)
}
}
func TestBootstrapRejectsUnconfiguredDatabase(t *testing.T) {
tmpDir := t.TempDir()
cfgPath := filepath.Join(tmpDir, "no-db.yaml")
if err := os.WriteFile(cfgPath, []byte("database:\n url: \"\"\n"), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
var stderr bytes.Buffer
cmd := rootCmd()
cmd.SetOut(os.Stderr)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--config", cfgPath, "principal", "bootstrap", "--alias", "edge"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when database.url is empty")
}
if !strings.Contains(err.Error(), "database.url is required") {
t.Fatalf("expected 'database.url is required' error, got: %v", err)
}
}
type failingWriter struct {
err error
}
func (w *failingWriter) Write(p []byte) (n int, err error) {
return 0, w.err
}
func TestPrincipalBootstrapReturnsStdoutWriteError(t *testing.T) {
dbFile, _ := newBootstrapTestStore(t)
cfgPath := writeBootstrapConfig(t, dbFile)
sentinelErr := errors.New("simulated write failure")
fw := &failingWriter{err: sentinelErr}
var stderr bytes.Buffer
cmd := rootCmd()
cmd.SetOut(fw)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"--config", cfgPath, "principal", "bootstrap", "--alias", "write-fail"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected error when stdout write fails")
}
if !errors.Is(err, sentinelErr) {
t.Fatalf("expected error wrapping sentinel, got: %v", err)
}
errStr := err.Error() + "\n" + stderr.String()
for _, word := range strings.Fields(errStr) {
if len(word) == 64 {
if _, hexErr := hex.DecodeString(word); hexErr == nil {
t.Fatalf("raw token leaked in error or stderr: %q", word)
}
}
}
}
func TestBootstrapCommandIsCLIOnlyRegistered(t *testing.T) {
root := rootCmd()
// Verify principal is a registered subcommand.
var found bool
for _, cmd := range root.Commands() {
if cmd.Name() == "principal" {
found = true
break
}
}
if !found {
t.Fatal("principal command must be registered on root")
}
// Verify bootstrap is a subcommand of principal.
principalCmd, _, err := root.Find([]string{"principal"})
if err != nil {
t.Fatalf("find principal: %v", err)
}
var bootstrapFound bool
for _, cmd := range principalCmd.Commands() {
if cmd.Name() == "bootstrap" {
bootstrapFound = true
break
}
}
if !bootstrapFound {
t.Fatal("bootstrap must be a subcommand of principal")
}
// Verify serve is still registered.
var serveFound bool
for _, cmd := range root.Commands() {
if cmd.Name() == "serve" {
serveFound = true
break
}
}
if !serveFound {
t.Fatal("serve command must still be registered on root")
}
}
func TestBootstrapCommandRemainsCLIOnly(t *testing.T) {
// Structural proof: the bootstrap command exists only as a Cobra command.
// No credential-bearing operation is added to the wire layer.
root := rootCmd()
// Find the bootstrap command.
principalCmd, _, err := root.Find([]string{"principal"})
if err != nil {
t.Fatalf("find principal: %v", err)
}
bootstrapCmd, _, err := principalCmd.Find([]string{"bootstrap"})
if err != nil {
t.Fatalf("find bootstrap: %v", err)
}
// bootstrap must have --alias flag but no wire-related flags.
var hasAlias, hasWireFlag bool
bootstrapCmd.Flags().VisitAll(func(f *pflag.Flag) {
if f.Name == "alias" {
hasAlias = true
}
if f.Name == "wire" || f.Name == "listen" || f.Name == "server" {
hasWireFlag = true
}
})
if !hasAlias {
t.Fatal("bootstrap must have --alias flag")
}
if hasWireFlag {
t.Fatal("bootstrap must not have wire-related flags")
}
// Verify the bootstrap command has no Run (it uses RunE) and no PersistentPreRun
// that could connect to a wire server.
if bootstrapCmd.Run != nil {
t.Fatal("bootstrap must use RunE, not Run")
}
}
func TestBootstrapManagementIsAbsentFromWireSources(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller failed")
}
repoRoot := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..", "..", ".."))
for _, source := range bootstrapWireSources(repoRoot) {
content, err := os.ReadFile(source.path)
if err != nil {
t.Fatalf("read wire source %s: %v", source.path, err)
}
if violation := source.check(string(content)); violation != "" {
t.Fatalf("wire source %s: %s", source.path, violation)
}
}
}
type bootstrapWireSource struct {
path string
check func(string) string
}
func bootstrapWireSources(repoRoot string) []bootstrapWireSource {
return []bootstrapWireSource{
{path: filepath.Join(repoRoot, "proto/iop/control.proto"), check: protoBootstrapManagementViolation},
{path: filepath.Join(repoRoot, "apps/control-plane/internal/wire/client.go"), check: clientServerBootstrapManagementViolation},
{path: filepath.Join(repoRoot, "apps/client/lib/iop_wire/client_wire_client.dart"), check: dartBootstrapManagementViolation},
{path: filepath.Join(repoRoot, "apps/client/lib/iop_wire/parser_map.dart"), check: dartBootstrapManagementViolation},
}
}
var protoMessageDeclarationPattern = regexp.MustCompile(`(?m)^\s*message\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{`)
var allowedPrincipalProjectionMessages = map[string]struct{}{
"ProjectedPrincipalToken": {},
"ProjectedPrincipalRoute": {},
"PrincipalProjection": {},
"PrincipalProjectionApplyRequest": {},
"PrincipalProjectionApplyResponse": {},
}
func protoBootstrapManagementViolation(content string) string {
for _, match := range protoMessageDeclarationPattern.FindAllStringSubmatch(content, -1) {
name := match[1]
lower := strings.ToLower(name)
if !strings.Contains(lower, "bootstrap") && !strings.Contains(lower, "credential") && !strings.Contains(lower, "principal") {
continue
}
if _, allowed := allowedPrincipalProjectionMessages[name]; allowed {
continue
}
return "forbidden management proto message " + name
}
return ""
}
func clientServerBootstrapManagementViolation(content string) string {
if strings.Contains(strings.ToLower(content), "credentialstore") {
return "must not import credentialstore"
}
return bootstrapManagementSymbolViolation(content)
}
func dartBootstrapManagementViolation(content string) string {
return bootstrapManagementSymbolViolation(content)
}
func bootstrapManagementSymbolViolation(content string) string {
lower := strings.ToLower(content)
for _, symbol := range []string{"bootstrap", "credential", "principal"} {
if strings.Contains(lower, symbol) {
return "forbidden management symbol " + symbol
}
}
return ""
}
func TestBootstrapManagementGuardRejectsForbiddenVariants(t *testing.T) {
for _, tc := range []struct {
name string
check func(string) string
content string
wantViolation bool
}{
{"proto principal create request", protoBootstrapManagementViolation, "message PrincipalCreateRequest {}", true},
{"proto credential request", protoBootstrapManagementViolation, "message CredentialRequest {}", true},
{"client lower camel create principal", clientServerBootstrapManagementViolation, "func createPrincipal() {}", true},
{"Dart parser principal create request", dartBootstrapManagementViolation, "'iop.PrincipalCreateRequest': PrincipalCreateRequest.fromBuffer,", true},
{"reserved projection foundation", protoBootstrapManagementViolation, "message ProjectedPrincipalToken {}\nmessage ProjectedPrincipalRoute {}\nmessage PrincipalProjection {}\nmessage PrincipalProjectionApplyRequest {}\nmessage PrincipalProjectionApplyResponse {}", false},
{"Client hello proto", protoBootstrapManagementViolation, "message ClientHelloRequest {}\nmessage ClientHelloResponse {}", false},
{"Client hello server", clientServerBootstrapManagementViolation, "func hello(req *iop.ClientHelloRequest) {}", false},
{"Client hello Dart", dartBootstrapManagementViolation, "Future<ClientHelloResponse> hello() async => ClientHelloResponse();", false},
} {
t.Run(tc.name, func(t *testing.T) {
violation := tc.check(tc.content)
if tc.wantViolation && violation == "" {
t.Fatalf("expected guard to reject %q", tc.content)
}
if !tc.wantViolation && violation != "" {
t.Fatalf("expected guard to allow %q, got %s", tc.content, violation)
}
})
}
}
// helper: writeBootstrapConfig writes a minimal config YAML pointing at the given database URL.
func writeBootstrapConfig(t *testing.T, dbFile string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "bootstrap-config.yaml")
body := "database:\n url: \"" + dbFile + "\"\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}
// sha256Hex returns the hex-encoded SHA-256 digest of data.
func sha256Hex(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}