Validates and activates the key like PUT /api/v1/license (same service call, audited as the system actor) and prints the license state without the key. Exit codes: 2 bad input, 3 key rejected, 4 license server unreachable or untrusted (nothing stored). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
6.8 KiB
Go
220 lines
6.8 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
func lookupFrom(m map[string]string) func(string) (string, bool) {
|
|
return func(k string) (string, bool) { v, ok := m[k]; return v, ok }
|
|
}
|
|
|
|
func TestRunVersionAndHelp(t *testing.T) {
|
|
var out, errOut bytes.Buffer
|
|
if code := run(context.Background(), []string{"version"}, lookupFrom(nil), nil, &out, &errOut); code != 0 {
|
|
t.Fatalf("version exit code %d", code)
|
|
}
|
|
if !strings.HasPrefix(out.String(), "ddi-server ") {
|
|
t.Errorf("version output %q", out.String())
|
|
}
|
|
out.Reset()
|
|
if code := run(context.Background(), []string{"help"}, lookupFrom(nil), nil, &out, &errOut); code != 0 || !strings.Contains(out.String(), "bootstrap-admin") {
|
|
t.Errorf("help: code %d, output %q", code, out.String())
|
|
}
|
|
}
|
|
|
|
func TestRunUnknownCommand(t *testing.T) {
|
|
var out, errOut bytes.Buffer
|
|
if code := run(context.Background(), []string{"frobnicate"}, lookupFrom(nil), nil, &out, &errOut); code != 2 {
|
|
t.Errorf("exit code %d, want 2", code)
|
|
}
|
|
}
|
|
|
|
func TestRunInvalidConfig(t *testing.T) {
|
|
var errOut bytes.Buffer
|
|
code := run(context.Background(), []string{"serve"}, lookupFrom(map[string]string{"DDI_HTTP_LISTEN": "bad"}), nil, io.Discard, &errOut)
|
|
if code != 1 || !strings.Contains(errOut.String(), "invalid configuration") {
|
|
t.Errorf("code %d, stderr %q", code, errOut.String())
|
|
}
|
|
}
|
|
|
|
func TestServeFailsWithoutMasterKey(t *testing.T) {
|
|
var errOut bytes.Buffer
|
|
env := map[string]string{"DDI_MASTER_KEY_FILE": filepath.Join(t.TempDir(), "missing.key"), "DDI_DATABASE_URL": "postgres://x"}
|
|
if code := run(context.Background(), []string{"serve"}, lookupFrom(env), nil, io.Discard, &errOut); code != 1 || !strings.Contains(errOut.String(), "master key") {
|
|
t.Errorf("code %d, stderr %q", code, errOut.String())
|
|
}
|
|
}
|
|
|
|
func TestGenMasterKey(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "sub", "master.key")
|
|
var out, errOut bytes.Buffer
|
|
if code := run(context.Background(), []string{"gen-master-key", "-out", path}, lookupFrom(nil), nil, &out, &errOut); code != 0 {
|
|
t.Fatalf("code %d: %s", code, errOut.String())
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || !strings.Contains(string(data), "k1 ") {
|
|
t.Fatalf("key file: %q %v", data, err)
|
|
}
|
|
if code := run(context.Background(), []string{"gen-master-key", "-out", path}, lookupFrom(nil), nil, &out, &errOut); code != 1 {
|
|
t.Error("existing key file must not be overwritten")
|
|
}
|
|
}
|
|
|
|
// testDatabase creates a private schema and returns a URL that uses it.
|
|
func testDatabase(t *testing.T) string {
|
|
t.Helper()
|
|
base := os.Getenv("DDI_TEST_DATABASE_URL")
|
|
if base == "" {
|
|
if os.Getenv("DDI_REQUIRE_DB_TESTS") == "1" {
|
|
t.Fatal("DDI_TEST_DATABASE_URL is not set but DDI_REQUIRE_DB_TESTS=1")
|
|
}
|
|
t.Skip("DDI_TEST_DATABASE_URL not set")
|
|
}
|
|
var b [6]byte
|
|
_, _ = rand.Read(b[:])
|
|
schema := "t_" + hex.EncodeToString(b[:])
|
|
ctx := context.Background()
|
|
conn, err := pgx.Connect(ctx, base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := conn.Exec(ctx, "CREATE SCHEMA "+schema); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = conn.Exec(context.Background(), "DROP SCHEMA "+schema+" CASCADE")
|
|
_ = conn.Close(context.Background())
|
|
})
|
|
u, err := url.Parse(base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
q := u.Query()
|
|
q.Set("search_path", schema)
|
|
u.RawQuery = q.Encode()
|
|
return u.String()
|
|
}
|
|
|
|
func freeAddr(t *testing.T) string {
|
|
t.Helper()
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
addr := l.Addr().String()
|
|
_ = l.Close()
|
|
return addr
|
|
}
|
|
|
|
// syncWriter serializes writes from the server goroutines.
|
|
type syncWriter struct {
|
|
mu sync.Mutex
|
|
w io.Writer
|
|
}
|
|
|
|
func (s *syncWriter) Write(p []byte) (int, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.w.Write(p)
|
|
}
|
|
|
|
// TestServeEndToEnd runs the real server against the test database.
|
|
func TestServeEndToEnd(t *testing.T) {
|
|
dbURL := testDatabase(t)
|
|
dir := t.TempDir()
|
|
keyFile := filepath.Join(dir, "master.key")
|
|
if code := run(context.Background(), []string{"gen-master-key", "-out", keyFile}, lookupFrom(nil), nil, io.Discard, io.Discard); code != 0 {
|
|
t.Fatal("gen-master-key failed")
|
|
}
|
|
addr := freeAddr(t)
|
|
env := map[string]string{
|
|
"DDI_HTTP_LISTEN": addr, "DDI_AGENT_API_LISTEN": freeAddr(t), "DDI_DATABASE_URL": dbURL, "DDI_MASTER_KEY_FILE": keyFile, "DDI_STATE_DIR": dir,
|
|
"KEYGEN_URL": "http://127.0.0.1:1", "KEYGEN_ACCOUNT_ID": "9de54794-8e94-4250-83f6-87e3d5686a0b",
|
|
"KEYGEN_PUBLIC_KEY": "40495568fa239e44736e28949ae2ae93f2c9b6e6bc6d917f4c8cc0eb629e3c62",
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
if code := run(context.Background(), []string{"bootstrap-admin", "-username", "root"}, lookupFrom(env), nil, &out, io.Discard); code != 0 {
|
|
t.Fatalf("bootstrap-admin failed: %s", out.String())
|
|
}
|
|
if !strings.Contains(out.String(), "username: root") || !strings.Contains(out.String(), "password: ") {
|
|
t.Errorf("bootstrap output %q", out.String())
|
|
}
|
|
if code := run(context.Background(), []string{"bootstrap-admin"}, lookupFrom(env), nil, io.Discard, io.Discard); code != 1 {
|
|
t.Error("second bootstrap must fail")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
done := make(chan int, 1)
|
|
var logs bytes.Buffer
|
|
go func() { done <- run(ctx, []string{"serve"}, lookupFrom(env), nil, io.Discard, &syncWriter{w: &logs}) }()
|
|
|
|
base := fmt.Sprintf("http://%s", addr)
|
|
get := func(path string) (int, string) {
|
|
t.Helper()
|
|
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, base+path, nil)
|
|
res, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return 0, err.Error()
|
|
}
|
|
defer res.Body.Close() //nolint:errcheck // test
|
|
b, _ := io.ReadAll(res.Body)
|
|
return res.StatusCode, string(b)
|
|
}
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
for {
|
|
if code, _ := get("/healthz"); code != 0 {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
cancel()
|
|
t.Fatalf("server did not come up:\n%s", logs.String())
|
|
}
|
|
time.Sleep(50 * time.Millisecond)
|
|
}
|
|
for path, want := range map[string]string{
|
|
"/readyz": `"database":"ok"`,
|
|
"/api/v1/system/info": `"license_state":"trial"`,
|
|
"/metrics": "ddi_license_writable 1",
|
|
} {
|
|
code, body := get(path)
|
|
if code != http.StatusOK || !strings.Contains(body, want) {
|
|
t.Errorf("%s: %d %q", path, code, body)
|
|
}
|
|
}
|
|
if _, body := get("/metrics"); !strings.Contains(body, `ddi_agents_connected{role="dhcp"} 0`) {
|
|
t.Errorf("agent metric missing: %q", body)
|
|
}
|
|
if code, body := get("/api/v1/networks"); code != http.StatusUnauthorized || !strings.Contains(body, "unauthenticated") {
|
|
t.Errorf("unauthenticated API access: %d %q", code, body)
|
|
}
|
|
|
|
cancel()
|
|
select {
|
|
case code := <-done:
|
|
if code != 0 {
|
|
t.Errorf("exit code %d:\n%s", code, logs.String())
|
|
}
|
|
case <-time.After(20 * time.Second):
|
|
t.Fatal("server did not shut down")
|
|
}
|
|
}
|