diff --git a/internal/service/intermediate_ca_test.go b/internal/service/intermediate_ca_test.go new file mode 100644 index 0000000..91e8725 --- /dev/null +++ b/internal/service/intermediate_ca_test.go @@ -0,0 +1,611 @@ +package service + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "math/big" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/certctl-io/certctl/internal/crypto/signer" + "github.com/certctl-io/certctl/internal/domain" + "github.com/certctl-io/certctl/internal/repository" +) + +// fakeIntermediateCARepo is an in-memory IntermediateCARepository for +// service-layer tests. WalkAncestry mirrors the recursive-CTE +// semantics shipped by the postgres adapter: leaf-first ordering, +// terminating at the row whose parent_ca_id IS NULL. The AssembleChain +// pin only carries weight if this fake produces the same shape the +// production adapter would. +type fakeIntermediateCARepo struct { + mu sync.Mutex + rows map[string]*domain.IntermediateCA + seq int +} + +func newFakeIntermediateCARepo() *fakeIntermediateCARepo { + return &fakeIntermediateCARepo{rows: make(map[string]*domain.IntermediateCA)} +} + +func (f *fakeIntermediateCARepo) Create(ctx context.Context, ca *domain.IntermediateCA) error { + f.mu.Lock() + defer f.mu.Unlock() + if ca.ID == "" { + f.seq++ + ca.ID = "ica-fake-" + strings.ToLower(stringn(f.seq)) + } + if _, exists := f.rows[ca.ID]; exists { + return repository.ErrAlreadyExists + } + if ca.CreatedAt.IsZero() { + ca.CreatedAt = time.Now().UTC() + } + if ca.UpdatedAt.IsZero() { + ca.UpdatedAt = ca.CreatedAt + } + cp := *ca + f.rows[ca.ID] = &cp + return nil +} + +func (f *fakeIntermediateCARepo) Get(ctx context.Context, id string) (*domain.IntermediateCA, error) { + f.mu.Lock() + defer f.mu.Unlock() + r, ok := f.rows[id] + if !ok { + return nil, repository.ErrNotFound + } + cp := *r + return &cp, nil +} + +func (f *fakeIntermediateCARepo) ListByIssuer(ctx context.Context, issuerID string) ([]*domain.IntermediateCA, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []*domain.IntermediateCA + for _, r := range f.rows { + if r.OwningIssuerID == issuerID { + cp := *r + out = append(out, &cp) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) + return out, nil +} + +func (f *fakeIntermediateCARepo) ListChildren(ctx context.Context, parentCAID string) ([]*domain.IntermediateCA, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []*domain.IntermediateCA + for _, r := range f.rows { + if r.ParentCAID != nil && *r.ParentCAID == parentCAID { + cp := *r + out = append(out, &cp) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.Before(out[j].CreatedAt) }) + return out, nil +} + +func (f *fakeIntermediateCARepo) UpdateState(ctx context.Context, id string, state domain.IntermediateCAState) error { + f.mu.Lock() + defer f.mu.Unlock() + r, ok := f.rows[id] + if !ok { + return repository.ErrNotFound + } + r.State = state + r.UpdatedAt = time.Now().UTC() + return nil +} + +func (f *fakeIntermediateCARepo) GetActiveRoot(ctx context.Context, issuerID string) (*domain.IntermediateCA, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, r := range f.rows { + if r.OwningIssuerID == issuerID && r.ParentCAID == nil && r.State == domain.IntermediateCAStateActive { + cp := *r + return &cp, nil + } + } + return nil, repository.ErrNotFound +} + +// WalkAncestry mirrors the postgres recursive-CTE: anchor on leafID, +// then iteratively follow parent_ca_id to the root. Ordering is +// leaf-first. Returns ErrNotFound when leafID does not exist (matching +// the postgres adapter's contract). +func (f *fakeIntermediateCARepo) WalkAncestry(ctx context.Context, leafID string) ([]*domain.IntermediateCA, error) { + f.mu.Lock() + defer f.mu.Unlock() + cur, ok := f.rows[leafID] + if !ok { + return nil, repository.ErrNotFound + } + var out []*domain.IntermediateCA + visited := map[string]bool{} + for cur != nil { + if visited[cur.ID] { + // Defense in depth: refuse cycles. Production schema's + // no-self-parent CHECK + the parent_ca_id FK make this + // unreachable; the fake is paranoid by construction. + break + } + visited[cur.ID] = true + cp := *cur + out = append(out, &cp) + if cur.ParentCAID == nil { + break + } + cur = f.rows[*cur.ParentCAID] + } + return out, nil +} + +func stringn(n int) string { + if n == 0 { + return "0" + } + const digits = "0123456789" + var b []byte + for n > 0 { + b = append([]byte{digits[n%10]}, b...) + n /= 10 + } + return string(b) +} + +// Compile-time interface guard. +var _ repository.IntermediateCARepository = (*fakeIntermediateCARepo)(nil) + +// testCAFixture is a one-shot helper that builds a self-signed root +// cert + key in process memory and adopts the key into a MemoryDriver +// under a stable ref. Returns the PEM-encoded cert, the +// signer.MemoryDriver, and the keyDriverID the service can pass to +// CreateRoot. +func testCAFixture(t *testing.T, drv *signer.MemoryDriver, ref string, subject pkix.Name, pathLen *int, ncs []domain.NameConstraint) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("ecdsa keygen: %v", err) + } + if err := drv.Adopt(ref, key); err != nil { + t.Fatalf("adopt key: %v", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + t.Fatalf("serial: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: subject, + Issuer: subject, // self-signed + NotBefore: time.Now().Add(-time.Hour).UTC(), + NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour).UTC(), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + if pathLen != nil { + tmpl.MaxPathLen = *pathLen + tmpl.MaxPathLenZero = (*pathLen == 0) + } + if len(ncs) > 0 { + var permitted, excluded []string + for _, nc := range ncs { + permitted = append(permitted, nc.Permitted...) + excluded = append(excluded, nc.Excluded...) + } + tmpl.PermittedDNSDomains = permitted + tmpl.ExcludedDNSDomains = excluded + tmpl.PermittedDNSDomainsCritical = true + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +// newTestService spins up an IntermediateCAService backed by the +// in-memory repo + MemoryDriver + a no-op audit service. +func newTestService(t *testing.T) (*IntermediateCAService, *fakeIntermediateCARepo, *signer.MemoryDriver, *IntermediateCAMetrics) { + t.Helper() + repo := newFakeIntermediateCARepo() + drv := signer.NewMemoryDriver() + auditRepo := &mockAuditRepo{} + auditSvc := NewAuditService(auditRepo) + metrics := NewIntermediateCAMetrics() + svc := NewIntermediateCAService(repo, nil, drv, auditSvc, metrics) + return svc, repo, drv, metrics +} + +// ==== Tests ==== + +// TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned +// pins the happy-path: a valid self-signed root cert + matching key +// gets persisted with parent_ca_id = NULL and state=active. +func TestIntermediateCA_CreateRoot_RegistersOperatorSuppliedSelfSigned(t *testing.T) { + svc, repo, drv, _ := newTestService(t) + pem := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil) + + id, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", + pem, "root-key", nil) + if err != nil { + t.Fatalf("CreateRoot: %v", err) + } + if !strings.HasPrefix(id, "ica-") { + t.Fatalf("expected ica- prefix, got %q", id) + } + got, err := repo.Get(context.Background(), id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if got.ParentCAID != nil { + t.Fatalf("expected ParentCAID nil for root, got %v", *got.ParentCAID) + } + if got.State != domain.IntermediateCAStateActive { + t.Fatalf("expected state=active, got %v", got.State) + } + if got.KeyDriverID != "root-key" { + t.Fatalf("expected KeyDriverID=root-key, got %q", got.KeyDriverID) + } +} + +// TestIntermediateCA_CreateRoot_RejectsNonSelfSigned pins RFC 5280 +// §3.2: a cert whose issuer ≠ subject (or whose signature does not +// verify under its own public key) MUST NOT be registered as a root. +func TestIntermediateCA_CreateRoot_RejectsNonSelfSigned(t *testing.T) { + svc, _, drv, _ := newTestService(t) + + // Build a cert whose issuer differs from subject — the validator + // in CreateRoot relies on cert.CheckSignatureFrom(cert), which fails + // when the embedded issuer DN doesn't match the cert's own public + // key. We achieve that by signing a "child" template with a DIFFERENT + // key under the same subject — so the public key the verifier loads + // from the cert (cert.PublicKey) does not match the actual signer. + signerKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + embeddedKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err := drv.Adopt("mismatched-key", signerKey); err != nil { + t.Fatalf("adopt: %v", err) + } + serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + tmpl := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "Imposter Root"}, + Issuer: pkix.Name{CommonName: "Imposter Root"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &embeddedKey.PublicKey, signerKey) + if err != nil { + t.Fatalf("create cert: %v", err) + } + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + + _, err = svc.CreateRoot(context.Background(), "iss-acme", "Bad Root", "user-admin", + pemBytes, "mismatched-key", nil) + if !errors.Is(err, ErrCANotSelfSigned) { + t.Fatalf("expected ErrCANotSelfSigned, got %v", err) + } +} + +// TestIntermediateCA_CreateRoot_RejectsKeyMismatch pins the second +// gate: cert is well-formed self-signed, but the operator-supplied +// keyDriverID resolves to a DIFFERENT key. CreateRoot must refuse +// before persisting the row. +func TestIntermediateCA_CreateRoot_RejectsKeyMismatch(t *testing.T) { + svc, _, drv, _ := newTestService(t) + pemBytes := testCAFixture(t, drv, "real-root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil) + // Adopt an unrelated key under a different ref. + stranger, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err := drv.Adopt("stranger-key", stranger); err != nil { + t.Fatalf("adopt: %v", err) + } + + _, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", + pemBytes, "stranger-key", nil) + if !errors.Is(err, ErrCAKeyMismatch) { + t.Fatalf("expected ErrCAKeyMismatch, got %v", err) + } +} + +// TestIntermediateCA_CreateChild_PathLenTighteningEnforced pins RFC +// 5280 §4.2.1.9: a child whose requested PathLenConstraint equals or +// exceeds the parent's MUST be rejected. +func TestIntermediateCA_CreateChild_PathLenTighteningEnforced(t *testing.T) { + svc, _, drv, _ := newTestService(t) + parentPathLen := 1 + rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, &parentPathLen, nil) + rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil) + if err != nil { + t.Fatalf("CreateRoot: %v", err) + } + + // Child requests path-len 1, parent has path-len 1 → child >= parent → reject. + requested := 1 + _, err = svc.CreateChild(context.Background(), rootID, "Acme Policy CA", "user-admin", + &CreateChildOptions{ + Subject: pkix.Name{CommonName: "Acme Policy CA"}, + Algorithm: signer.AlgorithmECDSAP256, + TTL: 365 * 24 * time.Hour, + PathLenConstraint: &requested, + }) + if !errors.Is(err, ErrPathLenExceeded) { + t.Fatalf("expected ErrPathLenExceeded, got %v", err) + } + + // Child requests path-len 0 (strictly less), under parent path-len 1 → ok. + tighter := 0 + if _, err := svc.CreateChild(context.Background(), rootID, "Acme Issuing CA", "user-admin", + &CreateChildOptions{ + Subject: pkix.Name{CommonName: "Acme Issuing CA"}, + Algorithm: signer.AlgorithmECDSAP256, + TTL: 365 * 24 * time.Hour, + PathLenConstraint: &tighter, + }); err != nil { + t.Fatalf("expected tightening to succeed, got %v", err) + } +} + +// TestIntermediateCA_CreateChild_NameConstraintsSubset pins RFC 5280 +// §4.2.1.10 enforcement at service layer. Parent permits "example.com"; +// child trying to widen with "evil.com" must be rejected, while a +// subdomain "internal.example.com" must succeed. +func TestIntermediateCA_CreateChild_NameConstraintsSubset(t *testing.T) { + svc, _, drv, _ := newTestService(t) + parentNCs := []domain.NameConstraint{{Permitted: []string{"example.com"}}} + rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, parentNCs) + rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil) + if err != nil { + t.Fatalf("CreateRoot: %v", err) + } + + // Widening is rejected. + _, err = svc.CreateChild(context.Background(), rootID, "Bad Child", "user-admin", + &CreateChildOptions{ + Subject: pkix.Name{CommonName: "Bad Child"}, + Algorithm: signer.AlgorithmECDSAP256, + TTL: 365 * 24 * time.Hour, + NameConstraints: []domain.NameConstraint{{Permitted: []string{"evil.com"}}}, + }) + if !errors.Is(err, ErrNameConstraintExceeded) { + t.Fatalf("expected ErrNameConstraintExceeded, got %v", err) + } + + // Subdomain narrowing succeeds. + if _, err := svc.CreateChild(context.Background(), rootID, "Acme Internal CA", "user-admin", + &CreateChildOptions{ + Subject: pkix.Name{CommonName: "Acme Internal CA"}, + Algorithm: signer.AlgorithmECDSAP256, + TTL: 365 * 24 * time.Hour, + NameConstraints: []domain.NameConstraint{{Permitted: []string{"internal.example.com"}}}, + }); err != nil { + t.Fatalf("expected subdomain narrowing to succeed, got %v", err) + } +} + +// TestIntermediateCA_AssembleChain_4DeepHierarchy is the LOAD-BEARING +// pin for AssembleChain: a 4-level hierarchy (root → policy → +// issuing-A → issuing-B-leaf) must produce a leaf-to-root PEM bundle +// with exactly 4 CERTIFICATE blocks in the right order. This is what +// the local connector's tree-mode code-path delegates to at +// IssueCertificate time. +func TestIntermediateCA_AssembleChain_4DeepHierarchy(t *testing.T) { + svc, _, drv, _ := newTestService(t) + // Root with path-len 3 (allows 3 layers of sub-CAs). + rootPathLen := 3 + rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, &rootPathLen, nil) + rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil) + if err != nil { + t.Fatalf("CreateRoot: %v", err) + } + + policyID, err := svc.CreateChild(context.Background(), rootID, "Policy CA", "user-admin", + &CreateChildOptions{ + Subject: pkix.Name{CommonName: "Acme Policy CA"}, + Algorithm: signer.AlgorithmECDSAP256, + TTL: 5 * 365 * 24 * time.Hour, + }) + if err != nil { + t.Fatalf("CreateChild policy: %v", err) + } + + issuingAID, err := svc.CreateChild(context.Background(), policyID, "Issuing A", "user-admin", + &CreateChildOptions{ + Subject: pkix.Name{CommonName: "Acme Issuing A"}, + Algorithm: signer.AlgorithmECDSAP256, + TTL: 2 * 365 * 24 * time.Hour, + }) + if err != nil { + t.Fatalf("CreateChild issuing A: %v", err) + } + + issuingBID, err := svc.CreateChild(context.Background(), issuingAID, "Issuing B", "user-admin", + &CreateChildOptions{ + Subject: pkix.Name{CommonName: "Acme Issuing B"}, + Algorithm: signer.AlgorithmECDSAP256, + TTL: 365 * 24 * time.Hour, + }) + if err != nil { + t.Fatalf("CreateChild issuing B: %v", err) + } + + chain, err := svc.AssembleChain(context.Background(), issuingBID) + if err != nil { + t.Fatalf("AssembleChain: %v", err) + } + count := strings.Count(chain, "BEGIN CERTIFICATE") + if count != 4 { + t.Fatalf("expected 4 CERTIFICATE blocks, got %d:\n%s", count, chain) + } + + // Verify each block parses + the chain is leaf → root by subject CN. + rest := []byte(chain) + wantSubjects := []string{"Acme Issuing B", "Acme Issuing A", "Acme Policy CA", "Acme Root"} + for i := 0; i < 4; i++ { + var block *pem.Block + block, rest = pem.Decode(rest) + if block == nil { + t.Fatalf("expected block %d, got nil", i) + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + t.Fatalf("parse block %d: %v", i, err) + } + if cert.Subject.CommonName != wantSubjects[i] { + t.Fatalf("block %d: expected CN=%q, got %q", i, wantSubjects[i], cert.Subject.CommonName) + } + } +} + +// TestIntermediateCA_Retire_RefusesIfActiveChildren pins drain-first +// semantics: a CA in retiring state with active children cannot be +// terminalized — the caller must retire the children first. +func TestIntermediateCA_Retire_RefusesIfActiveChildren(t *testing.T) { + svc, _, drv, _ := newTestService(t) + rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil) + rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil) + if err != nil { + t.Fatalf("CreateRoot: %v", err) + } + if _, err := svc.CreateChild(context.Background(), rootID, "Child", "user-admin", + &CreateChildOptions{ + Subject: pkix.Name{CommonName: "Child"}, + Algorithm: signer.AlgorithmECDSAP256, + TTL: 365 * 24 * time.Hour, + }); err != nil { + t.Fatalf("CreateChild: %v", err) + } + + // First call: active → retiring (no confirm needed). + if err := svc.Retire(context.Background(), rootID, "user-admin", "drain start", false); err != nil { + t.Fatalf("Retire (active→retiring): %v", err) + } + // Second call: retiring → retired with active child → must refuse. + err = svc.Retire(context.Background(), rootID, "user-admin", "terminalize", true) + if !errors.Is(err, ErrCAStillHasActiveChildren) { + t.Fatalf("expected ErrCAStillHasActiveChildren, got %v", err) + } +} + +// TestIntermediateCA_Retire_TwoPhaseConfirm pins the two-phase +// transition: first call moves active→retiring without a confirm +// flag; the second retiring→retired transition requires confirm=true. +func TestIntermediateCA_Retire_TwoPhaseConfirm(t *testing.T) { + svc, repo, drv, _ := newTestService(t) + rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil) + rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil) + if err != nil { + t.Fatalf("CreateRoot: %v", err) + } + + // First call (no confirm, no children): active → retiring. + if err := svc.Retire(context.Background(), rootID, "user-admin", "drain", false); err != nil { + t.Fatalf("first retire: %v", err) + } + got, _ := repo.Get(context.Background(), rootID) + if got.State != domain.IntermediateCAStateRetiring { + t.Fatalf("expected retiring, got %v", got.State) + } + + // Second call without confirm — must surface "pass confirm=true". + err = svc.Retire(context.Background(), rootID, "user-admin", "terminalize?", false) + if err == nil || !strings.Contains(err.Error(), "confirm=true") { + t.Fatalf("expected confirm=true error, got %v", err) + } + + // Second call with confirm: retiring → retired. + if err := svc.Retire(context.Background(), rootID, "user-admin", "terminalize", true); err != nil { + t.Fatalf("retire confirm: %v", err) + } + got, _ = repo.Get(context.Background(), rootID) + if got.State != domain.IntermediateCAStateRetired { + t.Fatalf("expected retired, got %v", got.State) + } +} + +// TestIntermediateCA_MetricsRecordedPerOutcome pins the metrics +// snapshot — every successful CreateRoot / CreateChild / Retire +// transition lands one row in the snapshot, dimensioned by +// (issuer_id, kind). +func TestIntermediateCA_MetricsRecordedPerOutcome(t *testing.T) { + svc, _, drv, metrics := newTestService(t) + + rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil) + rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil) + if err != nil { + t.Fatalf("CreateRoot: %v", err) + } + if _, err := svc.CreateChild(context.Background(), rootID, "Child", "user-admin", + &CreateChildOptions{ + Subject: pkix.Name{CommonName: "Child"}, + Algorithm: signer.AlgorithmECDSAP256, + TTL: 365 * 24 * time.Hour, + }); err != nil { + t.Fatalf("CreateChild: %v", err) + } + if err := svc.Retire(context.Background(), rootID, "user-admin", "drain", false); err != nil { + t.Fatalf("Retire: %v", err) + } + + snap := metrics.SnapshotIntermediateCA() + want := map[string]uint64{ + "iss-acme/create_root": 1, + "iss-acme/create_child": 1, + "iss-acme/retire_retiring": 1, + } + got := map[string]uint64{} + for _, e := range snap { + got[e.IssuerID+"/"+e.Kind] = e.Count + } + for k, v := range want { + if got[k] != v { + t.Fatalf("metric %s: expected %d, got %d (snapshot=%v)", k, v, got[k], got) + } + } +} + +// TestIntermediateCA_LoadHierarchy_FlatList pins LoadHierarchy: it +// returns every CA for an issuer, irrespective of state, ordered by +// created_at. Caller renders the tree from parent_ca_id. +func TestIntermediateCA_LoadHierarchy_FlatList(t *testing.T) { + svc, _, drv, _ := newTestService(t) + rootPEM := testCAFixture(t, drv, "root-key", pkix.Name{CommonName: "Acme Root"}, nil, nil) + rootID, err := svc.CreateRoot(context.Background(), "iss-acme", "Acme Root", "user-admin", rootPEM, "root-key", nil) + if err != nil { + t.Fatalf("CreateRoot: %v", err) + } + for i, name := range []string{"Policy CA", "Issuing CA"} { + _ = i + if _, err := svc.CreateChild(context.Background(), rootID, name, "user-admin", + &CreateChildOptions{ + Subject: pkix.Name{CommonName: name}, + Algorithm: signer.AlgorithmECDSAP256, + TTL: 365 * 24 * time.Hour, + }); err != nil { + t.Fatalf("CreateChild %s: %v", name, err) + } + } + + hier, err := svc.LoadHierarchy(context.Background(), "iss-acme") + if err != nil { + t.Fatalf("LoadHierarchy: %v", err) + } + if len(hier) != 3 { + t.Fatalf("expected 3 rows, got %d", len(hier)) + } +}