Files
OliveTin/service/internal/api/api_log_arguments_test.go
2026-07-29 00:30:57 +01:00

413 lines
12 KiB
Go

package api
import (
"context"
"testing"
"time"
"connectrpc.com/connect"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/executor"
)
func argumentAction(title, shell string, args []config.ActionArgument) *config.Action {
return &config.Action{
Title: title,
Shell: shell,
MaxConcurrent: 1,
Arguments: args,
}
}
func waitForLogArguments(t *testing.T, ex *executor.Executor, trackingID string) map[string]string {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
entry, ok := ex.GetLog(trackingID)
if ok && entry.Arguments != nil {
return entry.Arguments
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for arguments on log %s", trackingID)
return nil
}
func waitForLogFinished(t *testing.T, ex *executor.Executor, trackingID string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
entry, ok := ex.GetLog(trackingID)
if ok && entry.ExecutionFinished {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for execution to finish on log %s", trackingID)
}
func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, expected string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
entry, ok := ex.GetLog(trackingID)
if ok && entry.Justification == expected {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for justification %q on log %s", expected, trackingID)
}
func TestExecutionStatusIncludesStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
argumentAction("Ping host with stored args", "echo {{ host }}", []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
}),
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Arguments: []*apiv1.StartActionArgument{
{Name: "host", Value: "example.com"},
},
}))
require.NoError(t, err)
waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
statusResp, err := client.ExecutionStatus(context.Background(), connect.NewRequest(&apiv1.ExecutionStatusRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.NoError(t, err)
require.NotNil(t, statusResp.Msg.LogEntry)
require.Len(t, statusResp.Msg.LogEntry.Arguments, 1)
assert.Equal(t, "host", statusResp.Msg.LogEntry.Arguments[0].Name)
assert.Equal(t, "example.com", statusResp.Msg.LogEntry.Arguments[0].Value)
}
func TestExecutionStatusOmitsPasswordArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{
Title: "Connect",
Exec: []string{"echo", "{{ user }}"},
MaxConcurrent: 1,
Arguments: []config.ActionArgument{
{Name: "user", Type: "ascii_identifier"},
{Name: "pass", Type: "password"},
},
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Arguments: []*apiv1.StartActionArgument{
{Name: "user", Value: "alice"},
{Name: "pass", Value: "secret"},
},
}))
require.NoError(t, err)
waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
statusResp, err := client.ExecutionStatus(context.Background(), connect.NewRequest(&apiv1.ExecutionStatusRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.NoError(t, err)
require.NotNil(t, statusResp.Msg.LogEntry)
for _, arg := range statusResp.Msg.LogEntry.Arguments {
assert.NotEqual(t, "pass", arg.Name)
}
require.Len(t, statusResp.Msg.LogEntry.Arguments, 1)
assert.Equal(t, "user", statusResp.Msg.LogEntry.Arguments[0].Name)
assert.Equal(t, "alice", statusResp.Msg.LogEntry.Arguments[0].Value)
}
func TestRestartActionReusesStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
}),
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Arguments: []*apiv1.StartActionArgument{
{Name: "host", Value: "server-a"},
},
}))
require.NoError(t, err)
originalArgs := waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
assert.Equal(t, "server-a", originalArgs["host"])
waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.NoError(t, err)
require.NotEmpty(t, restartResp.Msg.ExecutionTrackingId)
assert.NotEqual(t, startResp.Msg.ExecutionTrackingId, restartResp.Msg.ExecutionTrackingId)
restartedArgs := waitForLogArguments(t, ex, restartResp.Msg.ExecutionTrackingId)
assert.Equal(t, "server-a", restartedArgs["host"])
}
func TestRestartActionRejectsIncompleteStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{
Title: "Connect",
Exec: []string{"echo", "{{ user }}"},
MaxConcurrent: 1,
Arguments: []config.ActionArgument{
{Name: "user", Type: "ascii_identifier"},
{Name: "pass", Type: "password"},
},
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Arguments: []*apiv1.StartActionArgument{
{Name: "user", Value: "alice"},
{Name: "pass", Value: "secret"},
},
}))
require.NoError(t, err)
_, err = client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.Error(t, err)
assert.Contains(t, err.Error(), "stored arguments are incomplete for restart")
}
func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
argumentAction("Ping host - reject missing required stored arg", "echo {{ host }}", []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
}),
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
trackingID := "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
ex.SetLog(trackingID, &executor.InternalLogEntry{
Binding: binding,
ExecutionFinished: true,
ExecutionTrackingID: trackingID,
Arguments: map[string]string{},
})
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
_, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: trackingID,
}))
require.Error(t, err)
assert.Contains(t, err.Error(), "stored arguments are incomplete for restart")
}
func TestLogEntryArgumentsToProto(t *testing.T) {
assert.Nil(t, logEntryArgumentsToProto(nil))
assert.Nil(t, logEntryArgumentsToProto(map[string]string{}))
out := logEntryArgumentsToProto(map[string]string{
"host": "example.com",
"port": "443",
})
require.Len(t, out, 2)
values := map[string]string{}
for _, arg := range out {
values[arg.Name] = arg.Value
}
assert.Equal(t, "example.com", values["host"])
assert.Equal(t, "443", values["port"])
}
func TestCopyStringMap(t *testing.T) {
source := map[string]string{"host": "example.com"}
copied := copyStringMap(source)
assert.Equal(t, source, copied)
source["host"] = "changed"
assert.Equal(t, "example.com", copied["host"])
empty := copyStringMap(nil)
assert.NotNil(t, empty)
assert.Empty(t, empty)
}
func TestRestartActionRequiresJustificationWhenMissingFromStoredLog(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{
Title: "Dangerous action",
Shell: "echo ok",
MaxConcurrent: 1,
Justification: config.JustificationRequiredNoTemplate,
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
trackingID := "manual-log-without-justification"
ex.SetLog(trackingID, &executor.InternalLogEntry{
Binding: binding,
ExecutionFinished: true,
ExecutionTrackingID: trackingID,
})
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
_, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: trackingID,
}))
require.Error(t, err)
assert.Contains(t, err.Error(), "justification")
}
func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
{
Title: "Dangerous action",
Shell: "echo ok",
MaxConcurrent: 1,
Justification: config.JustificationRequiredNoTemplate,
},
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Justification: "maintenance window",
}))
require.NoError(t, err)
waitForLogJustification(t, ex, startResp.Msg.ExecutionTrackingId, "maintenance window")
waitForLogFinished(t, ex, startResp.Msg.ExecutionTrackingId)
restartResp, err := client.RestartAction(context.Background(), connect.NewRequest(&apiv1.RestartActionRequest{
ExecutionTrackingId: startResp.Msg.ExecutionTrackingId,
}))
require.NoError(t, err)
waitForLogJustification(t, ex, restartResp.Msg.ExecutionTrackingId, "maintenance window")
}
func TestGetLogsIncludesStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"},
}),
}
ex := executor.DefaultExecutor(cfg)
ex.RebuildActionMap()
binding := ex.FindBindingWithNoEntity(cfg.Actions[0])
require.NotNil(t, binding)
ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
defer ts.Close()
startResp, err := client.StartAction(context.Background(), connect.NewRequest(&apiv1.StartActionRequest{
BindingId: binding.ID,
Arguments: []*apiv1.StartActionArgument{
{Name: "host", Value: "db-1"},
},
}))
require.NoError(t, err)
require.NotEmpty(t, startResp.Msg.ExecutionTrackingId)
waitForLogArguments(t, ex, startResp.Msg.ExecutionTrackingId)
logsResp, err := client.GetLogs(context.Background(), connect.NewRequest(&apiv1.GetLogsRequest{}))
require.NoError(t, err)
require.NotEmpty(t, logsResp.Msg.Logs)
var matched bool
for _, entry := range logsResp.Msg.Logs {
if entry.ExecutionTrackingId != startResp.Msg.ExecutionTrackingId {
continue
}
matched = true
require.Len(t, entry.Arguments, 1)
assert.Equal(t, "host", entry.Arguments[0].Name)
assert.Equal(t, "db-1", entry.Arguments[0].Value)
}
assert.True(t, matched, "expected log entry with stored arguments")
}