diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 9206808..8d3bcc0 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -11,15 +11,15 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v3 - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version: 1.20 + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version: 1.20.0 - - name: Build - run: go build -v ./... + - name: Build + run: go build -v ./... - - name: Test - run: go test -v ./... + - name: Test + run: go test -v ./... diff --git a/LICENSE b/LICENSE index 7cecae7..67e6180 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 HUMAN Security +Copyright (c) 2024 HUMAN Security. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 311c8a1..951931e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Run Tests](https://img.shields.io/github/actions/workflow/status/perimeterx/envite/go.yml?branch=main&logo=github&label=Run%20Tests)](https://github.com/PerimeterX/envite/actions/workflows/go.yml?query=branch%3Amain) [![Dependency Review](https://img.shields.io/github/actions/workflow/status/perimeterx/envite/dependency-review.yml?logo=github&label=Dependency%20Review)](https://github.com/PerimeterX/envite/actions/workflows/dependency-review.yml?query=branch%3Amain) [![Go Report Card](https://goreportcard.com/badge/github.com/perimeterx/envite)](https://goreportcard.com/report/github.com/perimeterx/envite) +![Manual Code Coverage](https://img.shields.io/badge/coverage-83.3%25-green) [![Go Reference](https://pkg.go.dev/badge/github.com/perimeterx/envite.svg)](https://pkg.go.dev/github.com/perimeterx/envite) [![Licence](https://img.shields.io/github/license/perimeterx/envite)](LICENSE) [![Latest Release](https://img.shields.io/github/v/release/perimeterx/envite)](https://github.com/PerimeterX/envite/releases) diff --git a/api.go b/api.go index 95f43d9..25f110e 100644 --- a/api.go +++ b/api.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "github.com/gorilla/mux" + "github.com/perimeterx/envite/ui" "io" "net/http" "strings" @@ -354,7 +355,7 @@ type webHandler struct { // initializing it with a file server that serves the bundled assets. func newWebHandler() *webHandler { return &webHandler{ - fileServer: http.FileServer(AssetFile()), + fileServer: http.FileServer(ui.AssetFile()), } } @@ -364,9 +365,9 @@ func (h webHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if len(path) > 0 && path[0] == '/' { path = path[1:] } - _, err := Asset(path) + _, err := ui.Asset(path) if err != nil { - data, _ := Asset(indexFilePath) + data, _ := ui.Asset(indexFilePath) http.ServeContent(w, r, indexFilePath, time.Time{}, bytes.NewReader(data)) } else { h.fileServer.ServeHTTP(w, r) diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..047f610 --- /dev/null +++ b/api_test.go @@ -0,0 +1,112 @@ +package envite + +import ( + "bytes" + "context" + "encoding/json" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestAPI(t *testing.T) { + component := &mockComponent{} + env, err := NewEnvironment( + "test-env", + NewComponentGraph().AddLayer(map[string]Component{"component": component}), + ) + assert.NoError(t, err) + assert.NotNil(t, env) + + call := func(handler http.Handler, request, response any) int { + var reqBody io.Reader + if request != nil { + var data []byte + data, err = json.Marshal(request) + assert.NoError(t, err) + reqBody = bytes.NewBuffer(data) + } + req := httptest.NewRequest(http.MethodGet, "/foo", reqBody) + if request != nil { + req.Header.Set(contentType, applicationJSON) + } + res := httptest.NewRecorder() + handler.ServeHTTP(res, req) + if response != nil { + var data []byte + data, err = io.ReadAll(res.Body) + assert.NoError(t, err) + err = json.Unmarshal(data, response) + assert.NoError(t, err) + } + return res.Code + } + + getStatusResponse := GetStatusResponse{} + status := call(getStatusHandler{env: env}, nil, &getStatusResponse) + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, "test-env", getStatusResponse.ID) + assert.Len(t, getStatusResponse.Components, 1) + assert.Len(t, getStatusResponse.Components[0], 1) + assert.Equal(t, "component", getStatusResponse.Components[0][0].ID) + assert.Equal(t, "mock", getStatusResponse.Components[0][0].Type) + + status = call(postStartHandler{env: env}, postStartRequest{ComponentID: "invalid"}, nil) + assert.Equal(t, http.StatusInternalServerError, status) + status = call(postStartHandler{env: env}, postStartRequest{ComponentID: "component"}, nil) + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, ComponentStatusRunning, component.status) + + status = call(postStopHandler{env: env}, postStopRequest{ComponentID: "invalid"}, nil) + assert.Equal(t, http.StatusInternalServerError, status) + status = call(postStopHandler{env: env}, postStopRequest{ComponentID: "component"}, nil) + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, ComponentStatusStopped, component.status) + + status = call(postApplyHandler{env: env}, postApplyRequest{EnabledComponentIDs: []string{"component"}}, nil) + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, ComponentStatusRunning, component.status) + status = call(postApplyHandler{env: env}, postApplyRequest{EnabledComponentIDs: []string{}}, nil) + assert.Equal(t, http.StatusOK, status) + assert.Equal(t, ComponentStatusStopped, component.status) + + err = env.StartAll(context.Background()) + assert.NoError(t, err) + assert.Equal(t, ComponentStatusRunning, component.status) + assert.False(t, component.cleanupCalled) + status = call(postStopAllHandler{env: env}, postStopAllRequest{Cleanup: true}, nil) + assert.Equal(t, http.StatusOK, status) + assert.True(t, component.cleanupCalled) + + req := httptest.NewRequest(http.MethodGet, "/foo", nil) + res := httptest.NewRecorder() + ctx, cancel := context.WithCancel(context.Background()) + req = req.WithContext(ctx) + startTime := time.Now() + go func() { + // wait one second and close client connection + time.Sleep(time.Second) + cancel() + }() + outputHandler := getOutputHandler{env: env} + outputHandler.ServeHTTP(res, req) + assert.True(t, time.Since(startTime) >= time.Second) + + wHandler := newWebHandler() + req = httptest.NewRequest(http.MethodGet, "/", nil) + res = httptest.NewRecorder() + wHandler.ServeHTTP(res, req) + assert.Equal(t, http.StatusOK, res.Code) + req = httptest.NewRequest(http.MethodGet, "/favicon.ico", nil) + res = httptest.NewRecorder() + wHandler.ServeHTTP(res, req) + assert.Equal(t, http.StatusOK, res.Code) + + req = httptest.NewRequest(http.MethodGet, "/", nil) + res = httptest.NewRecorder() + optionsHandler(res, req) + assert.Equal(t, http.StatusOK, res.Code) +} diff --git a/build-ui.sh b/build-ui.sh index 9ab9aa0..b2b80c4 100755 --- a/build-ui.sh +++ b/build-ui.sh @@ -11,4 +11,4 @@ fi npm --prefix ui run build -go-bindata -o static_files.go -pkg envite -prefix ui/build -fs ui/build/... +go-bindata -o ui/static_files.go -pkg ui -prefix ui/build -fs ui/build/... diff --git a/environment_test.go b/environment_test.go index 32d30e2..c430870 100644 --- a/environment_test.go +++ b/environment_test.go @@ -7,8 +7,10 @@ package envite import ( "context" "errors" + "fmt" "github.com/stretchr/testify/assert" "testing" + "time" ) type mockComponent struct { @@ -20,13 +22,15 @@ type mockComponent struct { cleanupCalled bool onStart func() onStop func() + w *Writer } func (m *mockComponent) Type() string { return "mock" } -func (m *mockComponent) AttachEnvironment(context.Context, *Environment, *Writer) error { +func (m *mockComponent) AttachEnvironment(_ context.Context, _ *Environment, w *Writer) error { + m.w = w return nil } @@ -184,6 +188,19 @@ func TestEnvironmentManagement(t *testing.T) { ) assert.NoError(t, err) + // Validate env components + components := env.Components() + assert.Len(t, components, 3) + assert.Contains(t, components, component1) + assert.Contains(t, components, component2) + assert.Contains(t, components, component3) + c, err := env.componentByID("component-1") + assert.NoError(t, err) + assert.Equal(t, component1, c) + c, err = env.componentByID("not exist") + assert.Nil(t, c) + assert.NotNil(t, err) + // Start err = env.Apply(context.Background(), []string{"component-1", "component-2", "component-3"}) assert.NoError(t, err) @@ -239,6 +256,39 @@ func TestEnvironmentManagement(t *testing.T) { assert.Equal(t, "component-3", status.Components[2][0].ID) assert.Equal(t, "mock", status.Components[2][0].Type) assert.Equal(t, ComponentStatusStopped, status.Components[2][0].Status) + + // Clear state + component1.initFlags() + component2.initFlags() + component3.initFlags() + + // Validate full start + err = env.StartAll(context.Background()) + assert.NoError(t, err) + assert.Equal(t, ComponentStatusRunning, component1.status) + assert.Equal(t, ComponentStatusRunning, component2.status) + assert.Equal(t, ComponentStatusRunning, component3.status) + + component1.onStart = nil + component2.onStart = nil + component3.onStart = nil + component1.onStop = nil + component2.onStop = nil + component3.onStop = nil + + // Validate manual stop + err = env.StopComponent(context.Background(), "component-1") + assert.NoError(t, err) + assert.Equal(t, ComponentStatusStopped, component1.status) + assert.Equal(t, ComponentStatusRunning, component2.status) + assert.Equal(t, ComponentStatusRunning, component3.status) + + // Validate manual start + err = env.StartComponent(context.Background(), "component-1") + assert.NoError(t, err) + assert.Equal(t, ComponentStatusRunning, component1.status) + assert.Equal(t, ComponentStatusRunning, component2.status) + assert.Equal(t, ComponentStatusRunning, component3.status) } func TestSelectiveAndZeroComponentApplication(t *testing.T) { @@ -315,3 +365,134 @@ func TestErrorHandlingDuringComponentManagement(t *testing.T) { assert.Error(t, err, "Apply should fail due to the 'fail-start' component") assert.Contains(t, err.Error(), "stop error", "Error should propagate when component fails to start") } + +func TestErrorHandlingDuringConstruction(t *testing.T) { + // Setup with empty ID + env, err := NewEnvironment( + "test-env", + NewComponentGraph(). + AddLayer(map[string]Component{"valid": &mockComponent{}}). + AddLayer(map[string]Component{"": &mockComponent{}}), + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "component id") + assert.Nil(t, env) + + // Setup with duplicate ID + env, err = NewEnvironment( + "test-env", + NewComponentGraph(). + AddLayer(map[string]Component{"valid": &mockComponent{}}). + AddLayer(map[string]Component{"valid": &mockComponent{}}), + ) + assert.Error(t, err) + assert.Contains(t, err.Error(), "component id") + assert.Nil(t, env) + + // Setup with empty env id + env, err = NewEnvironment( + "", + NewComponentGraph().AddLayer(map[string]Component{"valid": &mockComponent{}}), + ) + assert.Equal(t, ErrEmptyEnvID, err) + assert.Nil(t, env) + + // Setup with nil graph + env, err = NewEnvironment( + "test-env", + nil, + ) + assert.Equal(t, ErrNilGraph, err) + assert.Nil(t, env) +} + +func TestLogger(t *testing.T) { + logMessages := 0 + env, err := NewEnvironment( + "test-env", + NewComponentGraph().AddLayer(map[string]Component{"valid": &mockComponent{}}), + WithLogger(func(level LogLevel, message string) { + logMessages++ + }), + ) + assert.NoError(t, err) + assert.NotNil(t, env) + + // validate log messages + err = env.StartAll(context.Background()) + assert.NoError(t, err) + + assert.True(t, logMessages >= 2) +} + +func TestOutput(t *testing.T) { + component := &mockComponent{} + + env, err := NewEnvironment( + "test-env", + NewComponentGraph().AddLayer(map[string]Component{"component": component}), + ) + assert.NoError(t, err) + assert.NotNil(t, env) + someTime := time.Date(2000, 0, 0, 0, 0, 0, 0, time.UTC) + + // send messages + component.w.Write([]byte("bytes message\n")) + component.w.WriteString("string message\r\n") + component.w.WriteWithTime(someTime, []byte("bytes message with time")) + component.w.WriteStringWithTime(someTime, "string message with time") + component.w.WriteString(component.w.Color.Red("red message")) + component.w.WriteString(component.w.Color.Green("green message")) + component.w.WriteString(component.w.Color.Yellow("yellow message")) + component.w.WriteString(component.w.Color.Blue("blue message")) + component.w.WriteString(component.w.Color.Magenta("magenta message")) + component.w.WriteString(component.w.Color.Cyan("cyan message")) + + // validate messages + thisYear := fmt.Sprintf("component