사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
534 lines
16 KiB
Go
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[:])
|
|
}
|