package execution_test import ( "errors" "path/filepath" "testing" "time" "github.com/kami/hexis/internal/domain" "github.com/kami/hexis/internal/execution" "github.com/kami/hexis/internal/provider" "github.com/kami/hexis/internal/storage" ) type fakeProvider struct { name string delay time.Duration err error } func (p *fakeProvider) Name() string { return p.name } func (p *fakeProvider) Execute(capability *domain.Capability, req *domain.ExecuteRequest) (map[string]any, error) { if p.delay > 0 { time.Sleep(p.delay) } if p.err != nil { return nil, p.err } return map[string]any{"ok": true}, nil } func newTestEngine(t *testing.T, prov *fakeProvider) (*execution.Engine, *storage.Store) { t.Helper() dir := t.TempDir() store, err := storage.Open(filepath.Join(dir, "hexis.db")) if err != nil { t.Fatalf("open storage: %v", err) } t.Cleanup(func() { store.Close() }) reg := provider.NewRegistry() reg.Register(prov) return execution.New(store, reg), store } func mustCreateCapability(t *testing.T, store storage.Interface, mutate func(*domain.Capability)) *domain.Capability { t.Helper() now := time.Now().UTC() cap := &domain.Capability{ ID: domain.NewCapabilityID(), Name: "test.capability", Provider: "fake", Operation: "noop", Risk: "low", ReadOnly: false, Enabled: true, CreatedAt: now, UpdatedAt: now, Version: 1, TargetTypes: []string{}, Attributes: map[string]any{}, } if mutate != nil { mutate(cap) } if err := store.CreateCapability(cap); err != nil { t.Fatalf("create capability: %v", err) } return cap } func TestExecute_FreeTextTargetRejectedWhenBound(t *testing.T) { prov := &fakeProvider{name: "fake"} eng, store := newTestEngine(t, prov) cap := mustCreateCapability(t, store, func(c *domain.Capability) { c.TargetEntityID = "ent_bound_only" }) _, err := eng.Execute(&domain.ExecuteRequest{ CapabilityID: cap.ID, TargetEntityID: "ent_someone_else", }) if !errors.Is(err, domain.ErrCapabilityNotBound) { t.Fatalf("expected ErrCapabilityNotBound, got %v", err) } } func TestExecute_DisabledCapabilityRejected(t *testing.T) { prov := &fakeProvider{name: "fake"} eng, store := newTestEngine(t, prov) cap := mustCreateCapability(t, store, func(c *domain.Capability) { c.Enabled = false }) _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_x"}) if !errors.Is(err, domain.ErrCapabilityDisabled) { t.Fatalf("expected ErrCapabilityDisabled, got %v", err) } } func TestExecute_ConfirmationRequiredButMissing(t *testing.T) { prov := &fakeProvider{name: "fake"} eng, store := newTestEngine(t, prov) cap := mustCreateCapability(t, store, func(c *domain.Capability) { c.RequiresConfirmation = true }) _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_x"}) if !errors.Is(err, domain.ErrConfirmationRequired) { t.Fatalf("expected ErrConfirmationRequired, got %v", err) } } func TestExecute_ConfirmationValidSucceedsAndIsConsumed(t *testing.T) { prov := &fakeProvider{name: "fake"} eng, store := newTestEngine(t, prov) cap := mustCreateCapability(t, store, func(c *domain.Capability) { c.RequiresConfirmation = true }) args := map[string]any{"foo": "bar"} conf, err := eng.CreateConfirmation(cap.ID, "ent_x", "kami", args) if err != nil { t.Fatalf("create confirmation: %v", err) } res, err := eng.Execute(&domain.ExecuteRequest{ CapabilityID: cap.ID, TargetEntityID: "ent_x", Arguments: args, ConfirmationID: conf.ID, }) if err != nil { t.Fatalf("execute: %v", err) } if res.Execution.Status != domain.ExecutionSucceeded { t.Fatalf("expected succeeded, got %s", res.Execution.Status) } got, err := store.GetConfirmation(conf.ID) if err != nil { t.Fatalf("get confirmation: %v", err) } if got.State != domain.ConfirmationConsumed { t.Fatalf("expected confirmation consumed, got %s", got.State) } // Reusing the same confirmation must fail. _, err = eng.Execute(&domain.ExecuteRequest{ CapabilityID: cap.ID, TargetEntityID: "ent_x", Arguments: args, ConfirmationID: conf.ID, }) if !errors.Is(err, domain.ErrConfirmationConsumed) { t.Fatalf("expected ErrConfirmationConsumed on replay, got %v", err) } } func TestExecute_ConfirmationExpired(t *testing.T) { prov := &fakeProvider{name: "fake"} eng, store := newTestEngine(t, prov) cap := mustCreateCapability(t, store, func(c *domain.Capability) { c.RequiresConfirmation = true }) conf, err := eng.CreateConfirmation(cap.ID, "ent_x", "kami", nil) if err != nil { t.Fatalf("create confirmation: %v", err) } // Directly backdate the row's expiry in storage to simulate TTL elapse // without waiting out the real 120s TTL in a test. if _, err := store.DB().Exec(`UPDATE confirmations SET expires_at = ? WHERE id = ?`, time.Now().UTC().Add(-time.Minute).Format("2006-01-02T15:04:05.999999999Z07:00"), conf.ID); err != nil { t.Fatalf("backdate confirmation: %v", err) } _, err = eng.Execute(&domain.ExecuteRequest{ CapabilityID: cap.ID, TargetEntityID: "ent_x", ConfirmationID: conf.ID, }) if !errors.Is(err, domain.ErrConfirmationExpired) { t.Fatalf("expected ErrConfirmationExpired, got %v", err) } got, err := store.GetConfirmation(conf.ID) if err != nil { t.Fatalf("get confirmation: %v", err) } if got.State != domain.ConfirmationExpired { t.Fatalf("expected confirmation state expired, got %s", got.State) } } func TestExecute_ArgsHashMismatchRejected(t *testing.T) { prov := &fakeProvider{name: "fake"} eng, store := newTestEngine(t, prov) cap := mustCreateCapability(t, store, func(c *domain.Capability) { c.RequiresConfirmation = true }) conf, err := eng.CreateConfirmation(cap.ID, "ent_x", "kami", map[string]any{"n": 1}) if err != nil { t.Fatalf("create confirmation: %v", err) } _, err = eng.Execute(&domain.ExecuteRequest{ CapabilityID: cap.ID, TargetEntityID: "ent_x", Arguments: map[string]any{"n": 2}, ConfirmationID: conf.ID, }) if !errors.Is(err, domain.ErrConfirmationInvalid) { t.Fatalf("expected ErrConfirmationInvalid on args mismatch, got %v", err) } } func TestExecute_UnknownCapabilityVersionRejected(t *testing.T) { prov := &fakeProvider{name: "fake"} eng, store := newTestEngine(t, prov) cap := mustCreateCapability(t, store, func(c *domain.Capability) { c.RequiresConfirmation = true }) conf, err := eng.CreateConfirmation(cap.ID, "ent_x", "kami", nil) if err != nil { t.Fatalf("create confirmation: %v", err) } // Bump the capability version (e.g. via update) so the confirmation now // targets a stale version. cap.Description = "changed" if err := store.UpdateCapability(cap); err != nil { t.Fatalf("update capability: %v", err) } _, err = eng.Execute(&domain.ExecuteRequest{ CapabilityID: cap.ID, TargetEntityID: "ent_x", ConfirmationID: conf.ID, }) if !errors.Is(err, domain.ErrConfirmationInvalid) { t.Fatalf("expected ErrConfirmationInvalid on stale version, got %v", err) } } func TestExecute_ConcurrentInFlightRejected(t *testing.T) { prov := &fakeProvider{name: "fake", delay: 300 * time.Millisecond} eng, store := newTestEngine(t, prov) cap := mustCreateCapability(t, store, nil) done := make(chan struct{}) go func() { eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_x"}) close(done) }() // Give the first execution time to register as "started". time.Sleep(50 * time.Millisecond) _, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_x"}) if !errors.Is(err, domain.ErrExecutionInFlight) { t.Fatalf("expected ErrExecutionInFlight, got %v", err) } <-done } func TestExecute_TimeoutYieldsUnknownNotFailed(t *testing.T) { // Smallest supported capability timeout is 1s; use a provider slower // than that so the engine's context deadline fires first. prov := &fakeProvider{name: "slow", delay: 1500 * time.Millisecond} eng, store := newTestEngine(t, prov) cap := mustCreateCapability(t, store, func(c *domain.Capability) { c.TimeoutSeconds = 1 c.Provider = "slow" }) res, err := eng.Execute(&domain.ExecuteRequest{CapabilityID: cap.ID, TargetEntityID: "ent_x"}) if err != nil { t.Fatalf("execute: %v", err) } if res.Execution.Status != domain.ExecutionUnknown { t.Fatalf("expected status unknown on timeout, got %s", res.Execution.Status) } // Let the background provider goroutine finish so t.Cleanup can close // the store without a dangling write racing it. time.Sleep(1600 * time.Millisecond) }