diff --git a/.github/workflows/go-int.yml b/.github/workflows/go-int.yml new file mode 100644 index 000000000..0e8ce0068 --- /dev/null +++ b/.github/workflows/go-int.yml @@ -0,0 +1,26 @@ +--- +name: Go Integration + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.25' + - name: Install just + uses: extractions/setup-just@v3 + - name: Fetch justfiles + run: just fetch + - name: Linux tuning + run: just linux-tune + - name: Integration + run: just go::unit-int diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 7efd9ae9c..82678f1ae 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -24,7 +24,6 @@ jobs: run: | just go::deps just go::mod - just bats::deps - name: Test run: just test - name: Upload coverage reports to Codecov diff --git a/CLAUDE.md b/CLAUDE.md index ef1d02a6e..980ac6159 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,8 +22,9 @@ Quick reference for common commands: ```bash just deps # Install all dependencies -just test # Run all tests (lint + unit + coverage + bats) +just test # Run all tests (lint + unit + coverage) just go::unit # Run unit tests only +just go::unit-int # Run integration tests (requires running osapi) just go::vet # Run golangci-lint just go::fmt # Auto-format (gofumpt + golines) go test -run TestName -v ./internal/job/... # Run a single test @@ -32,7 +33,7 @@ go test -run TestName -v ./internal/job/... # Run a single test ## Architecture (Quick Reference) - **`cmd/`** - Cobra CLI commands (`client`, `node agent`, `api server`, `nats server`) -- **`internal/api/`** - Echo REST API by domain (`node/`, `job/`, `health/`, `audit/`, `common/`). Types are OpenAPI-generated (`*.gen.go`) +- **`internal/api/`** - Echo REST API by domain (`node/`, `job/`, `health/`, `audit/`, `common/`). Types are OpenAPI-generated (`*.gen.go`). Combined OpenAPI spec: `internal/api/gen/api.yaml` - **`internal/job/`** - Job domain types, subject routing. `client/` for high-level ops - **`internal/agent/`** - Node agent: consumer/handler/processor pipeline for job execution - **`internal/provider/`** - Operation implementations: `node/{host,disk,mem,load}`, `network/{dns,ping}` @@ -123,8 +124,9 @@ input must be validated, and the spec must declare how: `validation.Struct(request.Body)` for request bodies 3. A `400` response defined in the OpenAPI spec for endpoints that accept user input -4. An integration test (`*_integration_test.go`) that sends raw HTTP - through the full Echo middleware stack and verifies: +4. HTTP wiring tests (`TestXxxHTTP` / `TestXxxRBACHTTP` methods in the + `*_public_test.go` suite) that send raw HTTP through the full Echo + middleware stack and verify: - Validation errors return correct status codes and error messages - RBAC: 401 (no token), 403 (wrong permissions), 200 (valid token) @@ -140,13 +142,13 @@ Create `internal/api/{domain}/`: a 400 on failure. - Tests: `{operation}_get_public_test.go` (testify/suite, table-driven). Must cover validation failures (400), success, and error paths. -- Integration tests: `{operation}_get_integration_test.go` — sends raw - HTTP through the full Echo middleware stack. Every integration test - MUST include: - - **Validation tests**: valid input, invalid input (400 responses) - - **RBAC tests**: no token (401), wrong permissions (403), valid - token (200). Uses `api.New()` + `server.GetXxxHandler()` + - `server.RegisterHandlers()` to wire through `scopeMiddleware`. + Each public test suite also includes HTTP wiring methods: + - `TestXxxHTTP` — sends raw HTTP through the full Echo middleware + stack to verify validation (valid input, invalid input → 400). + - `TestXxxRBACHTTP` — verifies auth middleware: no token (401), + wrong permissions (403), valid token (200). Uses `api.New()` + + `server.GetXxxHandler()` + `server.RegisterHandlers()` to wire + through `scopeMiddleware`. See existing examples in `internal/api/job/` and `internal/api/audit/`. @@ -248,6 +250,16 @@ func FunctionName( ### Testing +Three test layers: +- **Unit tests** (`*_test.go`, `*_public_test.go`) — fast, mocked + dependencies, run with `just go::unit`. Includes `TestXxxHTTP` / + `TestXxxRBACHTTP` methods that send raw HTTP through real Echo + middleware with mocked backends. +- **Integration tests** (`test/integration/`) — build and start a real + `osapi` binary, exercise CLI commands end-to-end. Guarded by + `//go:build integration` tag, run with `just go::unit-int`. + +Conventions: - ALL tests in `internal/job/` MUST use `testify/suite` with table-driven patterns - Internal tests: `*_test.go` in same package (e.g., `package job`) for private functions - Public tests: `*_public_test.go` in test package (e.g., `package job_test`) for exported functions diff --git a/docs/docs/sidebar/development/development.md b/docs/docs/sidebar/development/development.md index a3ffe7aba..462e8d102 100644 --- a/docs/docs/sidebar/development/development.md +++ b/docs/docs/sidebar/development/development.md @@ -87,14 +87,15 @@ See the [Testing](testing.md) page for details on running tests and listing just recipes. ```bash -just test # Run all tests (lint + unit + coverage + bats) +just test # Run all tests (lint + unit + coverage) just go::unit # Run unit tests only -just bats::test # Run integration tests only +just go::unit-int # Run integration tests (requires running osapi) ``` Unit tests should follow the Go convention of being located in a file named `*_test.go` in the same package as the code being tested. Integration tests are -located in the `test` directory and executed by [Bats][]. +located in `test/integration/` and use a `//go:build integration` tag. They +build and start a real `osapi` binary, so they require no external setup. ### File naming @@ -143,6 +144,5 @@ be reasonable to split it in a few). Git squash and rebase is your friend! [Prettier]: https://prettier.io/ [Docusaurus]: https://docusaurus.io [Conventional Commits]: https://www.conventionalcommits.org -[Bats]: https://github.com/bats-core/bats-core [NATS CLI]: https://github.com/nats-io/natscli diff --git a/docs/docs/sidebar/development/testing.md b/docs/docs/sidebar/development/testing.md index 1d31e987c..c81a09267 100644 --- a/docs/docs/sidebar/development/testing.md +++ b/docs/docs/sidebar/development/testing.md @@ -10,18 +10,46 @@ Install dependencies: $ just deps ``` -To execute tests: +## Unit Tests + +Unit tests run with mocked dependencies and require no external services: + +```bash +$ just go::unit # Run unit tests +$ just go::unit-cov # Run with coverage report +$ just test # Run all checks (lint + unit + coverage) +``` + +Unit tests follow the Go convention of being located in `*_test.go` files in the +same package as the code being tested. Public API tests use the `_test` package +suffix in `*_public_test.go` files. Public test suites also include HTTP wiring +methods (`TestXxxHTTP`, `TestXxxRBACHTTP`) that send raw HTTP through the full +Echo middleware stack with mocked backends. + +## Integration Tests + +Integration tests build a real `osapi` binary, start all three components (NATS, +API server, agent), and exercise CLI commands end-to-end. They are guarded by a +`//go:build integration` tag and located in `test/integration/`: ```bash -$ just test +$ just go::unit-int # Run integration tests ``` +The test harness allocates random ports, generates a JWT, and starts the server +automatically — no manual setup required. Tests validate JSON responses from CLI +commands with `--json` output. + +## Formatting + Auto format code: ```bash $ just go::fmt ``` +## Listing Recipes + List helpful targets: ```bash diff --git a/internal/api/agent/agent_get_integration_test.go b/internal/api/agent/agent_get_integration_test.go deleted file mode 100644 index 2b7b242fa..000000000 --- a/internal/api/agent/agent_get_integration_test.go +++ /dev/null @@ -1,256 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package agent_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/agent" - agentGen "github.com/retr0h/osapi/internal/api/agent/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobtypes "github.com/retr0h/osapi/internal/job" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" - "github.com/retr0h/osapi/internal/provider/node/host" - "github.com/retr0h/osapi/internal/provider/node/load" - "github.com/retr0h/osapi/internal/provider/node/mem" -) - -type AgentGetIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *AgentGetIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *AgentGetIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *AgentGetIntegrationTestSuite) TestGetAgentDetailsValidation() { - tests := []struct { - name string - hostname string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when agent exists returns details", - hostname: "server1", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - GetAgent(gomock.Any(), "server1"). - Return(&jobtypes.AgentInfo{ - Hostname: "server1", - Labels: map[string]string{"group": "web"}, - RegisteredAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), - StartedAt: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), - OSInfo: &host.OSInfo{Distribution: "Ubuntu", Version: "24.04"}, - Uptime: 5 * time.Hour, - LoadAverages: &load.AverageStats{Load1: 0.5, Load5: 0.3, Load15: 0.2}, - MemoryStats: &mem.Stats{Total: 8388608, Free: 4194304, Cached: 2097152}, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"server1"`, `"Ready"`, `"Ubuntu"`}, - }, - { - name: "when agent not found returns 404", - hostname: "unknown", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - GetAgent(gomock.Any(), "unknown"). - Return(nil, fmt.Errorf("agent not found: unknown")) - return mock - }, - wantCode: http.StatusNotFound, - wantContains: []string{`"error"`}, - }, - { - name: "when client error returns 500", - hostname: "server1", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - GetAgent(gomock.Any(), "server1"). - Return(nil, fmt.Errorf("connection failed")) - return mock - }, - wantCode: http.StatusInternalServerError, - wantContains: []string{`"error"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - agentHandler := agent.New(suite.logger, jobMock) - strictHandler := agentGen.NewStrictHandler(agentHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - agentGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodGet, - fmt.Sprintf("/agent/%s", tc.hostname), - nil, - ) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacAgentGetTestSigningKey = "test-signing-key-for-rbac-agent-get" - -func (suite *AgentGetIntegrationTestSuite) TestGetAgentDetailsRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacAgentGetTestSigningKey, - []string{"read"}, - "test-user", - []string{"network:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with agent:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacAgentGetTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - GetAgent(gomock.Any(), "server1"). - Return(&jobtypes.AgentInfo{ - Hostname: "server1", - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"server1"`, `"Ready"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacAgentGetTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetAgentHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodGet, - "/agent/server1", - nil, - ) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestAgentGetIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(AgentGetIntegrationTestSuite)) -} diff --git a/internal/api/agent/agent_get_public_test.go b/internal/api/agent/agent_get_public_test.go index af51bdbbf..411d29918 100644 --- a/internal/api/agent/agent_get_public_test.go +++ b/internal/api/agent/agent_get_public_test.go @@ -24,14 +24,20 @@ import ( "context" "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "time" "github.com/golang/mock/gomock" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apiagent "github.com/retr0h/osapi/internal/api/agent" "github.com/retr0h/osapi/internal/api/agent/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobtypes "github.com/retr0h/osapi/internal/job" jobmocks "github.com/retr0h/osapi/internal/job/mocks" "github.com/retr0h/osapi/internal/provider/node/host" @@ -46,6 +52,8 @@ type AgentGetPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apiagent.Agent ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *AgentGetPublicTestSuite) SetupTest() { @@ -53,6 +61,8 @@ func (s *AgentGetPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apiagent.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *AgentGetPublicTestSuite) TearDownTest() { @@ -128,6 +138,194 @@ func (s *AgentGetPublicTestSuite) TestGetAgentDetails() { } } +func (s *AgentGetPublicTestSuite) TestGetAgentDetailsValidationHTTP() { + tests := []struct { + name string + hostname string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when agent exists returns details", + hostname: "server1", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + GetAgent(gomock.Any(), "server1"). + Return(&jobtypes.AgentInfo{ + Hostname: "server1", + Labels: map[string]string{"group": "web"}, + RegisteredAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + StartedAt: time.Date(2025, 12, 31, 0, 0, 0, 0, time.UTC), + OSInfo: &host.OSInfo{Distribution: "Ubuntu", Version: "24.04"}, + Uptime: 5 * time.Hour, + LoadAverages: &load.AverageStats{Load1: 0.5, Load5: 0.3, Load15: 0.2}, + MemoryStats: &mem.Stats{Total: 8388608, Free: 4194304, Cached: 2097152}, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"server1"`, `"Ready"`, `"Ubuntu"`}, + }, + { + name: "when agent not found returns 404", + hostname: "unknown", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + GetAgent(gomock.Any(), "unknown"). + Return(nil, fmt.Errorf("agent not found: unknown")) + return mock + }, + wantCode: http.StatusNotFound, + wantContains: []string{`"error"`}, + }, + { + name: "when client error returns 500", + hostname: "server1", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + GetAgent(gomock.Any(), "server1"). + Return(nil, fmt.Errorf("connection failed")) + return mock + }, + wantCode: http.StatusInternalServerError, + wantContains: []string{`"error"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + agentHandler := apiagent.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(agentHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodGet, + fmt.Sprintf("/agent/%s", tc.hostname), + nil, + ) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacAgentGetTestSigningKey = "test-signing-key-for-rbac-agent-get" + +func (s *AgentGetPublicTestSuite) TestGetAgentDetailsRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacAgentGetTestSigningKey, + []string{"read"}, + "test-user", + []string{"network:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with agent:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacAgentGetTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + GetAgent(gomock.Any(), "server1"). + Return(&jobtypes.AgentInfo{ + Hostname: "server1", + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"server1"`, `"Ready"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacAgentGetTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetAgentHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/agent/server1", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestAgentGetPublicTestSuite(t *testing.T) { suite.Run(t, new(AgentGetPublicTestSuite)) } diff --git a/internal/api/agent/agent_list_integration_test.go b/internal/api/agent/agent_list_integration_test.go deleted file mode 100644 index 7b0735693..000000000 --- a/internal/api/agent/agent_list_integration_test.go +++ /dev/null @@ -1,240 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package agent_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/agent" - agentGen "github.com/retr0h/osapi/internal/api/agent/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobtypes "github.com/retr0h/osapi/internal/job" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" -) - -type AgentListIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *AgentListIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *AgentListIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *AgentListIntegrationTestSuite) TestGetAgentValidation() { - tests := []struct { - name string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when agents exist returns agent list", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ListAgents(gomock.Any()). - Return([]jobtypes.AgentInfo{ - {Hostname: "server1"}, - {Hostname: "server2"}, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"total":2`, `"server1"`, `"server2"`, `"status":"Ready"`}, - }, - { - name: "when no agents returns empty list", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ListAgents(gomock.Any()). - Return([]jobtypes.AgentInfo{}, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"total":0`}, - }, - { - name: "when job client errors returns 500", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ListAgents(gomock.Any()). - Return(nil, assert.AnError) - return mock - }, - wantCode: http.StatusInternalServerError, - wantContains: []string{`"error"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - agentHandler := agent.New(suite.logger, jobMock) - strictHandler := agentGen.NewStrictHandler(agentHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - agentGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest(http.MethodGet, "/agent", nil) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacAgentListTestSigningKey = "test-signing-key-for-rbac-agent-list" - -func (suite *AgentListIntegrationTestSuite) TestGetAgentRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacAgentListTestSigningKey, - []string{"read"}, - "test-user", - []string{"network:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with agent:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacAgentListTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ListAgents(gomock.Any()). - Return([]jobtypes.AgentInfo{ - {Hostname: "server1"}, - {Hostname: "server2"}, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"total":2`, `"server1"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacAgentListTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetAgentHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodGet, - "/agent", - nil, - ) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestAgentListIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(AgentListIntegrationTestSuite)) -} diff --git a/internal/api/agent/agent_list_public_test.go b/internal/api/agent/agent_list_public_test.go index dbb92304d..ebcc8123e 100644 --- a/internal/api/agent/agent_list_public_test.go +++ b/internal/api/agent/agent_list_public_test.go @@ -22,7 +22,11 @@ package agent_test import ( "context" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "time" @@ -30,8 +34,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apiagent "github.com/retr0h/osapi/internal/api/agent" "github.com/retr0h/osapi/internal/api/agent/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobtypes "github.com/retr0h/osapi/internal/job" jobmocks "github.com/retr0h/osapi/internal/job/mocks" "github.com/retr0h/osapi/internal/provider/node/host" @@ -46,6 +53,8 @@ type AgentListPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apiagent.Agent ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *AgentListPublicTestSuite) SetupTest() { @@ -53,6 +62,8 @@ func (s *AgentListPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apiagent.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *AgentListPublicTestSuite) TearDownTest() { @@ -133,6 +144,181 @@ func (s *AgentListPublicTestSuite) TestGetAgent() { } } +func (s *AgentListPublicTestSuite) TestGetAgentValidationHTTP() { + tests := []struct { + name string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when agents exist returns agent list", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ListAgents(gomock.Any()). + Return([]jobtypes.AgentInfo{ + {Hostname: "server1"}, + {Hostname: "server2"}, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"total":2`, `"server1"`, `"server2"`, `"status":"Ready"`}, + }, + { + name: "when no agents returns empty list", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ListAgents(gomock.Any()). + Return([]jobtypes.AgentInfo{}, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"total":0`}, + }, + { + name: "when job client errors returns 500", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ListAgents(gomock.Any()). + Return(nil, assert.AnError) + return mock + }, + wantCode: http.StatusInternalServerError, + wantContains: []string{`"error"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + agentHandler := apiagent.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(agentHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, "/agent", nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacAgentListTestSigningKey = "test-signing-key-for-rbac-agent-list" + +func (s *AgentListPublicTestSuite) TestGetAgentRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacAgentListTestSigningKey, + []string{"read"}, + "test-user", + []string{"network:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with agent:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacAgentListTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ListAgents(gomock.Any()). + Return([]jobtypes.AgentInfo{ + {Hostname: "server1"}, + {Hostname: "server2"}, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"total":2`, `"server1"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacAgentListTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetAgentHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/agent", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestAgentListPublicTestSuite(t *testing.T) { suite.Run(t, new(AgentListPublicTestSuite)) } diff --git a/internal/api/audit/audit_export_integration_test.go b/internal/api/audit/audit_export_integration_test.go deleted file mode 100644 index c6292c330..000000000 --- a/internal/api/audit/audit_export_integration_test.go +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package audit_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - auditGen "github.com/retr0h/osapi/internal/api/audit/gen" - auditstore "github.com/retr0h/osapi/internal/audit" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" -) - -type AuditExportIntegrationTestSuite struct { - suite.Suite - - appConfig config.Config - logger *slog.Logger -} - -func (suite *AuditExportIntegrationTestSuite) SetupTest() { - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *AuditExportIntegrationTestSuite) TestGetAuditExportValidation() { - tests := []struct { - name string - store *fakeStore - wantCode int - wantContains []string - }{ - { - name: "when valid request returns entries", - store: &fakeStore{ - listAllEntries: []auditstore.Entry{ - { - ID: "550e8400-e29b-41d4-a716-446655440000", - Timestamp: time.Now(), - User: "user@example.com", - Roles: []string{"admin"}, - Method: "GET", - Path: "/node/hostname", - SourceIP: "127.0.0.1", - ResponseCode: 200, - DurationMs: 42, - }, - }, - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_items":1`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - a := api.New(suite.appConfig, suite.logger) - - auditHandler := newTestAuditHandler(suite.logger, tc.store) - strictHandler := auditGen.NewStrictHandler(auditHandler, nil) - auditGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodGet, - "/audit/export", - nil, - ) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacAuditExportTestSigningKey = "test-signing-key-for-rbac-export" - -func (suite *AuditExportIntegrationTestSuite) TestGetAuditExportRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacAuditExportTestSigningKey, - []string{"read"}, - "test-user", - []string{"job:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with audit:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacAuditExportTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_items":0`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - store := &fakeStore{ - listAllEntries: []auditstore.Entry{}, - } - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacAuditExportTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetAuditHandler(store) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodGet, - "/audit/export", - nil, - ) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestAuditExportIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(AuditExportIntegrationTestSuite)) -} diff --git a/internal/api/audit/audit_export_public_test.go b/internal/api/audit/audit_export_public_test.go index 2a1cfe4cd..7ca1ece0c 100644 --- a/internal/api/audit/audit_export_public_test.go +++ b/internal/api/audit/audit_export_public_test.go @@ -24,27 +24,37 @@ import ( "context" "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "time" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" auditapi "github.com/retr0h/osapi/internal/api/audit" "github.com/retr0h/osapi/internal/api/audit/gen" auditstore "github.com/retr0h/osapi/internal/audit" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" ) type AuditExportPublicTestSuite struct { suite.Suite - handler *auditapi.Audit - store *fakeStore - ctx context.Context + appConfig config.Config + logger *slog.Logger + handler *auditapi.Audit + store *fakeStore + ctx context.Context } func (s *AuditExportPublicTestSuite) SetupTest() { + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) s.store = &fakeStore{} - s.handler = auditapi.New(slog.Default(), s.store) + s.handler = auditapi.New(s.logger, s.store) s.ctx = context.Background() } @@ -140,6 +150,149 @@ func (s *AuditExportPublicTestSuite) TestGetAuditExport() { } } +func (s *AuditExportPublicTestSuite) TestGetAuditExportHTTP() { + tests := []struct { + name string + store *fakeStore + wantCode int + wantContains []string + }{ + { + name: "when valid request returns entries", + store: &fakeStore{ + listAllEntries: []auditstore.Entry{ + { + ID: "550e8400-e29b-41d4-a716-446655440000", + Timestamp: time.Now(), + User: "user@example.com", + Roles: []string{"admin"}, + Method: "GET", + Path: "/node/hostname", + SourceIP: "127.0.0.1", + ResponseCode: 200, + DurationMs: 42, + }, + }, + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_items":1`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + a := api.New(s.appConfig, s.logger) + + auditHandler := newTestAuditHandler(s.logger, tc.store) + strictHandler := gen.NewStrictHandler(auditHandler, nil) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodGet, + "/audit/export", + nil, + ) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacAuditExportTestSigningKey = "test-signing-key-for-rbac-export" + +func (s *AuditExportPublicTestSuite) TestGetAuditExportRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacAuditExportTestSigningKey, + []string{"read"}, + "test-user", + []string{"job:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with audit:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacAuditExportTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_items":0`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + store := &fakeStore{ + listAllEntries: []auditstore.Entry{}, + } + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacAuditExportTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetAuditHandler(store) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/audit/export", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestAuditExportPublicTestSuite(t *testing.T) { suite.Run(t, new(AuditExportPublicTestSuite)) } diff --git a/internal/api/audit/audit_get_integration_test.go b/internal/api/audit/audit_get_integration_test.go deleted file mode 100644 index 7f29427ce..000000000 --- a/internal/api/audit/audit_get_integration_test.go +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package audit_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - auditGen "github.com/retr0h/osapi/internal/api/audit/gen" - auditstore "github.com/retr0h/osapi/internal/audit" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" -) - -type AuditGetIntegrationTestSuite struct { - suite.Suite - - appConfig config.Config - logger *slog.Logger -} - -func (suite *AuditGetIntegrationTestSuite) SetupTest() { - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *AuditGetIntegrationTestSuite) TestGetAuditLogByIDValidation() { - tests := []struct { - name string - path string - store *fakeStore - wantCode int - wantContains []string - }{ - { - name: "when valid UUID returns entry", - path: "/audit/550e8400-e29b-41d4-a716-446655440000", - store: &fakeStore{ - getEntry: &auditstore.Entry{ - ID: "550e8400-e29b-41d4-a716-446655440000", - Timestamp: time.Now(), - User: "user@example.com", - Roles: []string{"admin"}, - Method: "GET", - Path: "/node/hostname", - SourceIP: "127.0.0.1", - ResponseCode: 200, - DurationMs: 42, - }, - }, - wantCode: http.StatusOK, - wantContains: []string{`"user":"user@example.com"`}, - }, - { - name: "when invalid UUID returns 400", - path: "/audit/not-a-uuid", - store: &fakeStore{}, - wantCode: http.StatusBadRequest, - wantContains: []string{}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - a := api.New(suite.appConfig, suite.logger) - - auditHandler := newTestAuditHandler(suite.logger, tc.store) - strictHandler := auditGen.NewStrictHandler(auditHandler, nil) - auditGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodGet, - tc.path, - nil, - ) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacAuditGetTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *AuditGetIntegrationTestSuite) TestGetAuditLogByIDRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacAuditGetTestSigningKey, - []string{"read"}, - "test-user", - []string{"job:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with audit:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacAuditGetTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - wantCode: http.StatusOK, - wantContains: []string{`"user":"user@example.com"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - store := &fakeStore{ - getEntry: &auditstore.Entry{ - ID: "550e8400-e29b-41d4-a716-446655440000", - Timestamp: time.Now(), - User: "user@example.com", - Roles: []string{"admin"}, - Method: "GET", - Path: "/node/hostname", - SourceIP: "127.0.0.1", - ResponseCode: 200, - DurationMs: 42, - }, - } - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacAuditGetTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetAuditHandler(store) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodGet, - "/audit/550e8400-e29b-41d4-a716-446655440000", - nil, - ) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestAuditGetIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(AuditGetIntegrationTestSuite)) -} diff --git a/internal/api/audit/audit_get_public_test.go b/internal/api/audit/audit_get_public_test.go index 26dc95763..f9df38a55 100644 --- a/internal/api/audit/audit_get_public_test.go +++ b/internal/api/audit/audit_get_public_test.go @@ -24,6 +24,9 @@ import ( "context" "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "time" @@ -31,22 +34,29 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" auditapi "github.com/retr0h/osapi/internal/api/audit" "github.com/retr0h/osapi/internal/api/audit/gen" auditstore "github.com/retr0h/osapi/internal/audit" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" ) type AuditGetPublicTestSuite struct { suite.Suite - handler *auditapi.Audit - store *fakeStore - ctx context.Context + appConfig config.Config + logger *slog.Logger + handler *auditapi.Audit + store *fakeStore + ctx context.Context } func (s *AuditGetPublicTestSuite) SetupTest() { + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) s.store = &fakeStore{} - s.handler = auditapi.New(slog.Default(), s.store) + s.handler = auditapi.New(s.logger, s.store) s.ctx = context.Background() } @@ -118,6 +128,166 @@ func (s *AuditGetPublicTestSuite) TestGetAuditLogByID() { } } +func (s *AuditGetPublicTestSuite) TestGetAuditLogByIDHTTP() { + tests := []struct { + name string + path string + store *fakeStore + wantCode int + wantContains []string + }{ + { + name: "when valid UUID returns entry", + path: "/audit/550e8400-e29b-41d4-a716-446655440000", + store: &fakeStore{ + getEntry: &auditstore.Entry{ + ID: "550e8400-e29b-41d4-a716-446655440000", + Timestamp: time.Now(), + User: "user@example.com", + Roles: []string{"admin"}, + Method: "GET", + Path: "/node/hostname", + SourceIP: "127.0.0.1", + ResponseCode: 200, + DurationMs: 42, + }, + }, + wantCode: http.StatusOK, + wantContains: []string{`"user":"user@example.com"`}, + }, + { + name: "when invalid UUID returns 400", + path: "/audit/not-a-uuid", + store: &fakeStore{}, + wantCode: http.StatusBadRequest, + wantContains: []string{}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + a := api.New(s.appConfig, s.logger) + + auditHandler := newTestAuditHandler(s.logger, tc.store) + strictHandler := gen.NewStrictHandler(auditHandler, nil) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodGet, + tc.path, + nil, + ) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacAuditGetTestSigningKey = "test-signing-key-for-rbac-integration" + +func (s *AuditGetPublicTestSuite) TestGetAuditLogByIDRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacAuditGetTestSigningKey, + []string{"read"}, + "test-user", + []string{"job:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with audit:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacAuditGetTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + wantCode: http.StatusOK, + wantContains: []string{`"user":"user@example.com"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + store := &fakeStore{ + getEntry: &auditstore.Entry{ + ID: "550e8400-e29b-41d4-a716-446655440000", + Timestamp: time.Now(), + User: "user@example.com", + Roles: []string{"admin"}, + Method: "GET", + Path: "/node/hostname", + SourceIP: "127.0.0.1", + ResponseCode: 200, + DurationMs: 42, + }, + } + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacAuditGetTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetAuditHandler(store) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/audit/550e8400-e29b-41d4-a716-446655440000", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestAuditGetPublicTestSuite(t *testing.T) { suite.Run(t, new(AuditGetPublicTestSuite)) } diff --git a/internal/api/audit/audit_list_integration_test.go b/internal/api/audit/audit_list_integration_test.go deleted file mode 100644 index f1d8d0943..000000000 --- a/internal/api/audit/audit_list_integration_test.go +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package audit_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - auditGen "github.com/retr0h/osapi/internal/api/audit/gen" - auditstore "github.com/retr0h/osapi/internal/audit" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" -) - -type AuditListIntegrationTestSuite struct { - suite.Suite - - appConfig config.Config - logger *slog.Logger -} - -func (suite *AuditListIntegrationTestSuite) SetupTest() { - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *AuditListIntegrationTestSuite) TestGetAuditLogsValidation() { - tests := []struct { - name string - query string - store *fakeStore - wantCode int - wantContains []string - }{ - { - name: "when valid request returns entries", - query: "", - store: &fakeStore{ - listEntries: []auditstore.Entry{ - { - ID: "550e8400-e29b-41d4-a716-446655440000", - Timestamp: time.Now(), - User: "user@example.com", - Roles: []string{"admin"}, - Method: "GET", - Path: "/node/hostname", - SourceIP: "127.0.0.1", - ResponseCode: 200, - DurationMs: 42, - }, - }, - listTotal: 1, - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_items":1`}, - }, - { - name: "when valid limit and offset params", - query: "?limit=5&offset=10", - store: &fakeStore{ - listEntries: []auditstore.Entry{}, - listTotal: 0, - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_items":0`}, - }, - { - name: "when limit is zero returns 400", - query: "?limit=0", - store: &fakeStore{}, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`}, - }, - { - name: "when limit exceeds maximum returns 400", - query: "?limit=200", - store: &fakeStore{}, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`}, - }, - { - name: "when offset is negative returns 400", - query: "?offset=-1", - store: &fakeStore{}, - wantCode: http.StatusBadRequest, - wantContains: []string{}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - a := api.New(suite.appConfig, suite.logger) - - auditHandler := newTestAuditHandler(suite.logger, tc.store) - strictHandler := auditGen.NewStrictHandler(auditHandler, nil) - auditGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodGet, - "/audit"+tc.query, - nil, - ) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacAuditListTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *AuditListIntegrationTestSuite) TestGetAuditLogsRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacAuditListTestSigningKey, - []string{"read"}, - "test-user", - []string{"job:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with audit:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacAuditListTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_items":0`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - store := &fakeStore{ - listEntries: []auditstore.Entry{}, - listTotal: 0, - } - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacAuditListTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetAuditHandler(store) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodGet, - "/audit", - nil, - ) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestAuditListIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(AuditListIntegrationTestSuite)) -} diff --git a/internal/api/audit/audit_list_public_test.go b/internal/api/audit/audit_list_public_test.go index 24265fc34..ec9d81493 100644 --- a/internal/api/audit/audit_list_public_test.go +++ b/internal/api/audit/audit_list_public_test.go @@ -24,27 +24,37 @@ import ( "context" "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "time" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" auditapi "github.com/retr0h/osapi/internal/api/audit" "github.com/retr0h/osapi/internal/api/audit/gen" auditstore "github.com/retr0h/osapi/internal/audit" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" ) type AuditListPublicTestSuite struct { suite.Suite - handler *auditapi.Audit - store *fakeStore - ctx context.Context + appConfig config.Config + logger *slog.Logger + handler *auditapi.Audit + store *fakeStore + ctx context.Context } func (s *AuditListPublicTestSuite) SetupTest() { + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) s.store = &fakeStore{} - s.handler = auditapi.New(slog.Default(), s.store) + s.handler = auditapi.New(s.logger, s.store) s.ctx = context.Background() } @@ -192,6 +202,184 @@ func (s *AuditListPublicTestSuite) TestGetAuditLogs() { } } +func (s *AuditListPublicTestSuite) TestGetAuditLogsHTTP() { + tests := []struct { + name string + query string + store *fakeStore + wantCode int + wantContains []string + }{ + { + name: "when valid request returns entries", + query: "", + store: &fakeStore{ + listEntries: []auditstore.Entry{ + { + ID: "550e8400-e29b-41d4-a716-446655440000", + Timestamp: time.Now(), + User: "user@example.com", + Roles: []string{"admin"}, + Method: "GET", + Path: "/node/hostname", + SourceIP: "127.0.0.1", + ResponseCode: 200, + DurationMs: 42, + }, + }, + listTotal: 1, + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_items":1`}, + }, + { + name: "when valid limit and offset params", + query: "?limit=5&offset=10", + store: &fakeStore{ + listEntries: []auditstore.Entry{}, + listTotal: 0, + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_items":0`}, + }, + { + name: "when limit is zero returns 400", + query: "?limit=0", + store: &fakeStore{}, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`}, + }, + { + name: "when limit exceeds maximum returns 400", + query: "?limit=200", + store: &fakeStore{}, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`}, + }, + { + name: "when offset is negative returns 400", + query: "?offset=-1", + store: &fakeStore{}, + wantCode: http.StatusBadRequest, + wantContains: []string{}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + a := api.New(s.appConfig, s.logger) + + auditHandler := newTestAuditHandler(s.logger, tc.store) + strictHandler := gen.NewStrictHandler(auditHandler, nil) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodGet, + "/audit"+tc.query, + nil, + ) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacAuditListTestSigningKey = "test-signing-key-for-rbac-integration" + +func (s *AuditListPublicTestSuite) TestGetAuditLogsRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacAuditListTestSigningKey, + []string{"read"}, + "test-user", + []string{"job:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with audit:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacAuditListTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_items":0`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + store := &fakeStore{ + listEntries: []auditstore.Entry{}, + listTotal: 0, + } + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacAuditListTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetAuditHandler(store) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/audit", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestAuditListPublicTestSuite(t *testing.T) { suite.Run(t, new(AuditListPublicTestSuite)) } diff --git a/internal/api/gen/api.yaml b/internal/api/gen/api.yaml new file mode 100644 index 000000000..0701c753d --- /dev/null +++ b/internal/api/gen/api.yaml @@ -0,0 +1,2409 @@ +openapi: 3.0.0 +info: + title: Agent Management API + version: 1.0.0 +tags: + - name: Agent_Management_API_agent_operations + x-displayName: Agent + description: Operations related to agent discovery and details. + - name: Audit_Log_API_audit + x-displayName: Audit + description: Audit log endpoints for viewing API activity. + - name: OSAPI_-_A_CRUD_API_for_managing_Linux_systems_info + x-displayName: Info + description: Operations related to the info endpoint. + - name: Health_Check_API_health + x-displayName: Health + description: Health check endpoints for liveness, readiness, and detailed status. + - name: Job_Management_API_job_operations + x-displayName: Job + description: Operations related to the job queue. + - name: Node_Management_API_node_operations + x-displayName: Node + description: Operations related to the node endpoint. + - name: Node_Management_API_node_status + x-displayName: Node/Status + description: Operations related to node status endpoint. + - name: Node_Management_API_network_operations + x-displayName: Node/Network + description: Network operations on a target node. + - name: Node_Management_API_dns_operations + x-displayName: Node/Network/DNS + description: DNS configuration operations on a target node. + - name: Node_Management_API_command_operations + x-displayName: Node/Command + description: Command execution on a target node. +paths: + /agent: + servers: [] + get: + summary: List active agents + description: Discover all active agents in the fleet. + tags: + - Agent_Management_API_agent_operations + security: + - BearerAuth: + - agent:read + responses: + '200': + description: List of active agents. + content: + application/json: + schema: + $ref: '#/components/schemas/ListAgentsResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error discovering agents. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /agent/{hostname}: + servers: [] + get: + summary: Get agent details + description: Get detailed information about a specific agent by hostname. + operationId: getAgentDetails + tags: + - Agent_Management_API_agent_operations + security: + - BearerAuth: + - agent:read + parameters: + - name: hostname + in: path + required: true + schema: + type: string + description: The hostname of the agent to retrieve. + responses: + '200': + description: Agent details. + content: + application/json: + schema: + $ref: '#/components/schemas/AgentInfo' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Agent not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving agent details. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /audit: + servers: [] + get: + operationId: GetAuditLogs + summary: List audit log entries + description: Returns a paginated list of audit log entries, newest first. + tags: + - Audit_Log_API_audit + security: + - BearerAuth: + - audit:read + parameters: + - name: limit + in: query + required: false + x-oapi-codegen-extra-tags: + validate: omitempty,min=1,max=100 + schema: + type: integer + default: 20 + minimum: 1 + maximum: 100 + description: Maximum number of entries to return. + - name: offset + in: query + required: false + x-oapi-codegen-extra-tags: + validate: omitempty,min=0 + schema: + type: integer + default: 0 + minimum: 0 + description: Number of entries to skip. + responses: + '200': + description: List of audit entries. + content: + application/json: + schema: + $ref: '#/components/schemas/ListAuditResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /audit/export: + servers: [] + get: + operationId: GetAuditExport + summary: Export all audit log entries + description: Returns all audit log entries without pagination for export. + tags: + - Audit_Log_API_audit + security: + - BearerAuth: + - audit:read + responses: + '200': + description: All audit entries. + content: + application/json: + schema: + $ref: '#/components/schemas/ListAuditResponse' + '401': + description: Unauthorized - API key required. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /audit/{id}: + servers: [] + get: + operationId: GetAuditLogByID + summary: Get a single audit log entry + description: Returns a single audit log entry by ID. + tags: + - Audit_Log_API_audit + security: + - BearerAuth: + - audit:read + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + description: The audit entry ID. + responses: + '200': + description: Audit entry found. + content: + application/json: + schema: + $ref: '#/components/schemas/AuditEntryResponse' + '401': + description: Unauthorized - API key required. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Audit entry not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Internal server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /version: + servers: + - url: http://127.0.0.1:8080 + description: Mock API (local). + get: + summary: Retrieve the software version + description: Get the current version of the software running on the system. + tags: + - OSAPI_-_A_CRUD_API_for_managing_Linux_systems_info + responses: + '400': + description: A common JSON error response. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /health: + servers: [] + get: + operationId: GetHealth + summary: Liveness probe + description: Returns 200 if the process is alive. No checks performed. + tags: + - Health_Check_API_health + responses: + '200': + description: Process is alive. + content: + application/json: + schema: + $ref: '#/components/schemas/HealthResponse' + /health/ready: + servers: [] + get: + operationId: GetHealthReady + summary: Readiness probe + description: Returns 200 when the service is ready to accept traffic. + tags: + - Health_Check_API_health + responses: + '200': + description: Service is ready. + content: + application/json: + schema: + $ref: '#/components/schemas/ReadyResponse' + '503': + description: Service is not ready. + content: + application/json: + schema: + $ref: '#/components/schemas/ReadyResponse' + /health/status: + servers: [] + get: + operationId: GetHealthStatus + summary: System status and component health + description: >- + Returns per-component health status with system metrics. Requires + authentication. + tags: + - Health_Check_API_health + security: + - BearerAuth: + - health:read + responses: + '200': + description: All components healthy. + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + '401': + description: Unauthorized - API key required. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '503': + description: One or more components unhealthy. + content: + application/json: + schema: + $ref: '#/components/schemas/StatusResponse' + /job: + servers: [] + post: + summary: Create a new job + description: Submit a new job to the queue for processing. + tags: + - Job_Management_API_job_operations + security: + - BearerAuth: + - job:write + requestBody: + description: The job to create. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateJobRequest' + responses: + '201': + description: The job was created successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateJobResponse' + '400': + description: Invalid request payload. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error creating job. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + summary: List jobs + description: Retrieve jobs, optionally filtered by status. + tags: + - Job_Management_API_job_operations + security: + - BearerAuth: + - job:read + parameters: + - name: status + in: query + required: false + x-oapi-codegen-extra-tags: + validate: >- + omitempty,oneof=submitted processing completed failed + partial_failure + schema: + type: string + enum: + - submitted + - processing + - completed + - failed + - partial_failure + description: Filter jobs by status. + - name: limit + in: query + required: false + x-oapi-codegen-extra-tags: + validate: omitempty,min=0 + schema: + type: integer + minimum: 0 + default: 10 + description: Maximum number of jobs to return. Use 0 for no limit. + - name: offset + in: query + required: false + x-oapi-codegen-extra-tags: + validate: omitempty,min=0 + schema: + type: integer + minimum: 0 + default: 0 + description: Number of jobs to skip for pagination. + responses: + '200': + description: A list of jobs. + content: + application/json: + schema: + $ref: '#/components/schemas/ListJobsResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error listing jobs. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /job/status: + servers: [] + get: + summary: Get queue statistics + description: Retrieve statistics about the job queue. + tags: + - Job_Management_API_job_operations + security: + - BearerAuth: + - job:read + responses: + '200': + description: Queue statistics. + content: + application/json: + schema: + $ref: '#/components/schemas/QueueStatsResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving queue statistics. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /job/{id}: + servers: [] + get: + summary: Get job detail + description: Retrieve details of a specific job by its ID. + tags: + - Job_Management_API_job_operations + operationId: GetJobByID + security: + - BearerAuth: + - job:read + parameters: + - name: id + in: path + required: true + description: UUID of the job to retrieve. + schema: + type: string + format: uuid + x-oapi-codegen-extra-tags: + validate: required,uuid + responses: + '200': + description: The job details. + content: + application/json: + schema: + $ref: '#/components/schemas/JobDetailResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Job not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving job. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Delete a job + description: Delete a specific job by its ID. + tags: + - Job_Management_API_job_operations + operationId: DeleteJobByID + security: + - BearerAuth: + - job:write + parameters: + - name: id + in: path + required: true + description: UUID of the job to delete. + schema: + type: string + format: uuid + x-oapi-codegen-extra-tags: + validate: required,uuid + responses: + '204': + description: Job deleted successfully. No content returned. + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Job not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error deleting job. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /job/{id}/retry: + servers: [] + post: + summary: Retry a job + description: >- + Create a new job using the same operation data as an existing job. The + original job is preserved. Returns the new job ID. + tags: + - Job_Management_API_job_operations + operationId: RetryJobByID + security: + - BearerAuth: + - job:write + parameters: + - name: id + in: path + required: true + description: UUID of the job to retry. + schema: + type: string + format: uuid + x-oapi-codegen-extra-tags: + validate: required,uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/RetryJobRequest' + responses: + '201': + description: The retry job was created successfully. + content: + application/json: + schema: + $ref: '#/components/schemas/CreateJobResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': + description: Job not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrying job. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}: + servers: [] + get: + summary: Retrieve node status + description: > + Get the current status of the node including hostname, uptime, load + averages, memory, and disk usage. + tags: + - Node_Management_API_node_status + operationId: GetNodeStatus + security: + - BearerAuth: + - node:read + parameters: + - $ref: '#/components/parameters/Hostname' + responses: + '200': + description: | + A JSON object containing the node's status information. + content: + application/json: + schema: + $ref: '#/components/schemas/NodeStatusCollectionResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving node status. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/hostname: + servers: [] + get: + summary: Retrieve node hostname + description: Get the current hostname and labels of the node. + tags: + - Node_Management_API_node_operations + operationId: GetNodeHostname + security: + - BearerAuth: + - node:read + parameters: + - $ref: '#/components/parameters/Hostname' + responses: + '200': + description: A JSON object containing the node's hostname. + content: + application/json: + schema: + $ref: '#/components/schemas/HostnameCollectionResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving hostname. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/disk: + servers: [] + get: + summary: Retrieve disk usage + description: Get disk usage information for the target node. + tags: + - Node_Management_API_node_operations + operationId: GetNodeDisk + security: + - BearerAuth: + - node:read + parameters: + - $ref: '#/components/parameters/Hostname' + responses: + '200': + description: Disk usage information. + content: + application/json: + schema: + $ref: '#/components/schemas/DiskCollectionResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving disk usage. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/memory: + servers: [] + get: + summary: Retrieve memory stats + description: Get memory usage information for the target node. + tags: + - Node_Management_API_node_operations + operationId: GetNodeMemory + security: + - BearerAuth: + - node:read + parameters: + - $ref: '#/components/parameters/Hostname' + responses: + '200': + description: Memory usage information. + content: + application/json: + schema: + $ref: '#/components/schemas/MemoryCollectionResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving memory stats. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/load: + servers: [] + get: + summary: Retrieve load averages + description: Get load average information for the target node. + tags: + - Node_Management_API_node_operations + operationId: GetNodeLoad + security: + - BearerAuth: + - node:read + parameters: + - $ref: '#/components/parameters/Hostname' + responses: + '200': + description: Load average information. + content: + application/json: + schema: + $ref: '#/components/schemas/LoadCollectionResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving load averages. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/os: + servers: [] + get: + summary: Retrieve OS info + description: Get operating system information for the target node. + tags: + - Node_Management_API_node_operations + operationId: GetNodeOS + security: + - BearerAuth: + - node:read + parameters: + - $ref: '#/components/parameters/Hostname' + responses: + '200': + description: Operating system information. + content: + application/json: + schema: + $ref: '#/components/schemas/OSInfoCollectionResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving OS info. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/uptime: + servers: [] + get: + summary: Retrieve uptime + description: Get uptime information for the target node. + tags: + - Node_Management_API_node_operations + operationId: GetNodeUptime + security: + - BearerAuth: + - node:read + parameters: + - $ref: '#/components/parameters/Hostname' + responses: + '200': + description: Uptime information. + content: + application/json: + schema: + $ref: '#/components/schemas/UptimeCollectionResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving uptime. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/network/ping: + servers: [] + post: + summary: Ping a remote server + description: | + Send a ping to a remote server to verify network connectivity. + tags: + - Node_Management_API_network_operations + operationId: PostNodeNetworkPing + security: + - BearerAuth: + - network:write + parameters: + - $ref: '#/components/parameters/Hostname' + requestBody: + description: The server to ping. + required: true + content: + application/json: + schema: + type: object + properties: + address: + type: string + description: > + The IP address of the server to ping. Supports both IPv4 and + IPv6. + example: 8.8.8.8 + x-oapi-codegen-extra-tags: + validate: required,ip + required: + - address + responses: + '200': + description: Successful ping response. + content: + application/json: + schema: + $ref: '#/components/schemas/PingCollectionResponse' + '400': + description: Invalid request payload. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error performing the ping operation. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/network/dns/{interfaceName}: + servers: [] + get: + summary: List DNS servers + description: > + Retrieve the list of currently configured DNS servers for a specific + network interface. + tags: + - Node_Management_API_dns_operations + operationId: GetNodeNetworkDNSByInterface + security: + - BearerAuth: + - network:read + parameters: + - $ref: '#/components/parameters/Hostname' + - name: interfaceName + in: path + required: true + x-oapi-codegen-extra-tags: + validate: required,alphanum + schema: + type: string + description: > + The name of the network interface to retrieve DNS configuration for. + Must only contain letters and numbers. + responses: + '200': + description: List of DNS servers. + content: + application/json: + schema: + $ref: '#/components/schemas/DNSConfigCollectionResponse' + '400': + description: Invalid interface name provided. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving DNS servers. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/network/dns: + servers: [] + put: + summary: Update DNS servers + description: Update the system's DNS server configuration. + tags: + - Node_Management_API_dns_operations + operationId: PutNodeNetworkDNS + security: + - BearerAuth: + - network:write + parameters: + - $ref: '#/components/parameters/Hostname' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DNSConfigUpdateRequest' + responses: + '202': + description: DNS servers update successfully accepted. + content: + application/json: + schema: + $ref: '#/components/schemas/DNSUpdateCollectionResponse' + '400': + description: Invalid input. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error updating DNS servers. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/command/exec: + servers: [] + post: + summary: Execute a command + description: > + Execute a command directly without a shell. This is the safer option as + it does not interpret shell metacharacters. + tags: + - Node_Management_API_command_operations + operationId: PostNodeCommandExec + security: + - BearerAuth: + - command:execute + parameters: + - $ref: '#/components/parameters/Hostname' + requestBody: + description: The command to execute. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommandExecRequest' + responses: + '202': + description: Command execution accepted. + content: + application/json: + schema: + $ref: '#/components/schemas/CommandResultCollectionResponse' + '400': + description: Invalid request payload. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error executing command. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/command/shell: + servers: [] + post: + summary: Execute a shell command + description: > + Execute a command through /bin/sh -c. Supports shell features like + pipes, redirects, and variable expansion. + tags: + - Node_Management_API_command_operations + operationId: PostNodeCommandShell + security: + - BearerAuth: + - command:execute + parameters: + - $ref: '#/components/parameters/Hostname' + requestBody: + description: The shell command to execute. + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CommandShellRequest' + responses: + '202': + description: Shell command execution accepted. + content: + application/json: + schema: + $ref: '#/components/schemas/CommandResultCollectionResponse' + '400': + description: Invalid request payload. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error executing shell command. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + ErrorResponse: + type: object + properties: + error: + type: string + description: A description of the error that occurred. + example: Failed to retrieve status. + details: + type: string + description: Additional details about the error. + example: Failed due to network timeout. + code: + type: integer + description: The error code. + example: 500 + ListAgentsResponse: + type: object + properties: + agents: + type: array + items: + $ref: '#/components/schemas/AgentInfo' + total: + type: integer + description: Total number of active agents. + required: + - agents + - total + AgentInfo: + type: object + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: + - Ready + - NotReady + description: The current status of the agent. + labels: + type: object + additionalProperties: + type: string + description: Key-value labels configured on the agent. + registered_at: + type: string + format: date-time + description: When the agent last refreshed its heartbeat. + started_at: + type: string + format: date-time + description: When the agent process started. + os_info: + $ref: '#/components/schemas/OSInfoResponse' + uptime: + type: string + description: The system uptime. + load_average: + $ref: '#/components/schemas/LoadAverageResponse' + memory: + $ref: '#/components/schemas/MemoryResponse' + required: + - hostname + - status + LoadAverageResponse: + type: object + description: The system load averages for 1, 5, and 15 minutes. + properties: + 1min: + type: number + description: Load average for the last 1 minute. + example: 0.32 + 5min: + type: number + description: Load average for the last 5 minutes. + example: 0.28 + 15min: + type: number + description: Load average for the last 15 minutes. + example: 0.25 + required: + - 1min + - 5min + - 15min + MemoryResponse: + type: object + description: Memory usage information. + properties: + total: + type: integer + description: Total memory in bytes. + example: 8388608 + free: + type: integer + description: Free memory in bytes. + example: 2097152 + used: + type: integer + description: Used memory in bytes. + example: 4194304 + required: + - total + - free + - used + OSInfoResponse: + type: object + description: Operating system information. + properties: + distribution: + type: string + description: The name of the Linux distribution. + example: Ubuntu + version: + type: string + description: The version of the Linux distribution. + example: '20.04' + required: + - distribution + - version + AuditEntry: + type: object + properties: + id: + type: string + format: uuid + description: Unique identifier for the audit entry. + example: 550e8400-e29b-41d4-a716-446655440000 + timestamp: + type: string + format: date-time + description: When the request was processed. + example: '2026-02-21T10:30:00Z' + user: + type: string + description: Authenticated user (JWT subject). + example: ops@example.com + roles: + type: array + items: + type: string + description: Roles from the JWT token. + example: + - admin + method: + type: string + description: HTTP method. + example: GET + path: + type: string + description: Request URL path. + example: /node/hostname + operation_id: + type: string + description: OpenAPI operation ID. + example: GetNodeHostname + source_ip: + type: string + description: Client IP address. + example: 192.168.1.100 + response_code: + type: integer + description: HTTP response status code. + example: 200 + duration_ms: + type: integer + format: int64 + description: Request duration in milliseconds. + example: 42 + required: + - id + - timestamp + - user + - roles + - method + - path + - source_ip + - response_code + - duration_ms + AuditEntryResponse: + type: object + properties: + entry: + $ref: '#/components/schemas/AuditEntry' + required: + - entry + ListAuditResponse: + type: object + properties: + total_items: + type: integer + description: Total number of audit entries. + example: 150 + items: + type: array + items: + $ref: '#/components/schemas/AuditEntry' + description: The audit entries for this page. + required: + - total_items + - items + HealthResponse: + type: object + properties: + status: + type: string + description: Health status. + example: ok + required: + - status + ReadyResponse: + type: object + properties: + status: + type: string + description: Readiness status. + example: ready + error: + type: string + description: Error message when not ready. + required: + - status + ComponentHealth: + type: object + properties: + status: + type: string + description: Component health status. + example: ok + error: + type: string + description: Error message when component is unhealthy. + required: + - status + NATSInfo: + type: object + properties: + url: + type: string + description: Connected NATS server URL. + example: nats://localhost:4222 + version: + type: string + description: NATS server version. + example: 2.10.0 + required: + - url + - version + StreamInfo: + type: object + properties: + name: + type: string + description: Stream name. + example: JOBS + messages: + type: integer + description: Number of messages in the stream. + example: 42 + bytes: + type: integer + description: Total bytes in the stream. + example: 1024 + consumers: + type: integer + description: Number of consumers on the stream. + example: 1 + required: + - name + - messages + - bytes + - consumers + KVBucketInfo: + type: object + properties: + name: + type: string + description: KV bucket name. + example: job-queue + keys: + type: integer + description: Number of keys in the bucket. + example: 10 + bytes: + type: integer + description: Total bytes in the bucket. + example: 2048 + required: + - name + - keys + - bytes + JobStats: + type: object + properties: + total: + type: integer + description: Total number of jobs. + example: 100 + unprocessed: + type: integer + description: Number of unprocessed jobs. + example: 5 + processing: + type: integer + description: Number of jobs currently processing. + example: 2 + completed: + type: integer + description: Number of completed jobs. + example: 90 + failed: + type: integer + description: Number of failed jobs. + example: 3 + dlq: + type: integer + description: Number of jobs in the dead letter queue. + example: 0 + required: + - total + - unprocessed + - processing + - completed + - failed + - dlq + AgentStats: + type: object + properties: + total: + type: integer + description: Total number of registered agents. + example: 5 + ready: + type: integer + description: Number of agents with Ready status. + example: 5 + agents: + type: array + items: + $ref: '#/components/schemas/AgentDetail' + description: Per-agent registration details. + required: + - total + - ready + AgentDetail: + type: object + properties: + hostname: + type: string + description: Agent hostname. + example: web-01 + labels: + type: string + description: Formatted label string. + example: group=web.prod + registered: + type: string + description: Time since last heartbeat registration. + example: 15s ago + required: + - hostname + - registered + ConsumerStats: + type: object + properties: + total: + type: integer + description: Total number of JetStream consumers. + example: 2 + consumers: + type: array + items: + $ref: '#/components/schemas/ConsumerDetail' + description: Per-consumer details. + required: + - total + ConsumerDetail: + type: object + properties: + name: + type: string + description: Consumer name. + example: query_any_web_01 + pending: + type: integer + description: Messages not yet delivered. + example: 0 + ack_pending: + type: integer + description: Messages delivered but not yet acknowledged. + example: 3 + redelivered: + type: integer + description: Messages redelivered and not yet acknowledged. + example: 0 + required: + - name + - pending + - ack_pending + - redelivered + StatusResponse: + type: object + properties: + status: + type: string + description: Overall health status. + example: ok + components: + type: object + additionalProperties: + $ref: '#/components/schemas/ComponentHealth' + description: Per-component health status. + version: + type: string + description: Application version. + example: 0.1.0 + uptime: + type: string + description: Time since server started. + example: 2h30m + nats: + $ref: '#/components/schemas/NATSInfo' + streams: + type: array + items: + $ref: '#/components/schemas/StreamInfo' + description: JetStream stream statistics. + kv_buckets: + type: array + items: + $ref: '#/components/schemas/KVBucketInfo' + description: KV bucket statistics. + jobs: + $ref: '#/components/schemas/JobStats' + agents: + $ref: '#/components/schemas/AgentStats' + consumers: + $ref: '#/components/schemas/ConsumerStats' + required: + - status + - components + - version + - uptime + CreateJobRequest: + type: object + properties: + operation: + type: object + description: The operation to perform, as a JSON object. + additionalProperties: true + x-oapi-codegen-extra-tags: + validate: required + target_hostname: + type: string + description: The target hostname for routing (_any, _all, or specific hostname). + example: _any + x-oapi-codegen-extra-tags: + validate: required,min=1 + required: + - operation + - target_hostname + CreateJobResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: Unique identifier for the created job. + example: 550e8400-e29b-41d4-a716-446655440000 + status: + type: string + description: Initial status of the job. + example: unprocessed + revision: + type: integer + format: int64 + description: The KV revision number. + example: 1 + timestamp: + type: string + description: Creation timestamp. + example: '2025-06-14T10:00:00Z' + required: + - job_id + - status + RetryJobRequest: + type: object + properties: + target_hostname: + type: string + description: >- + Override target hostname for the retried job. Defaults to _any if + not specified. + example: _any + x-oapi-codegen-extra-tags: + validate: omitempty,min=1 + ListJobsResponse: + type: object + properties: + total_items: + type: integer + description: Total number of jobs matching the filter. + example: 42 + items: + type: array + items: + $ref: '#/components/schemas/JobDetailResponse' + JobDetailResponse: + type: object + properties: + id: + type: string + format: uuid + description: Unique identifier of the job. + status: + type: string + description: Current status of the job. + created: + type: string + description: Creation timestamp. + operation: + type: object + description: The operation data. + additionalProperties: true + result: + description: The result data if completed. + error: + type: string + description: Error message if failed. + hostname: + type: string + description: Agent hostname that processed the job. + updated_at: + type: string + description: Last update timestamp. + responses: + type: object + description: Per-agent response data for broadcast jobs. + additionalProperties: + type: object + properties: + status: + type: string + data: + description: Agent result data. + error: + type: string + hostname: + type: string + agent_states: + type: object + description: Per-agent processing state for broadcast jobs. + additionalProperties: + type: object + properties: + status: + type: string + error: + type: string + duration: + type: string + timeline: + type: array + description: Chronological sequence of job lifecycle events. + items: + type: object + properties: + timestamp: + type: string + description: ISO 8601 timestamp of the event. + event: + type: string + description: >- + Event type (submitted, acknowledged, started, completed, + failed, retried). + hostname: + type: string + description: Agent or source that generated the event. + message: + type: string + description: Human-readable description of the event. + error: + type: string + description: Error details if applicable. + QueueStatsResponse: + type: object + properties: + total_jobs: + type: integer + description: Total number of jobs in the queue. + example: 42 + status_counts: + type: object + additionalProperties: + type: integer + description: Count of jobs by status. + operation_counts: + type: object + additionalProperties: + type: integer + description: Count of jobs by operation type. + dlq_count: + type: integer + description: Number of jobs in the dead letter queue. + DiskResponse: + type: object + description: Local disk usage information. + properties: + name: + type: string + description: Disk identifier, e.g., "/dev/sda1". + example: /dev/sda1 + total: + type: integer + description: Total disk space in bytes. + example: 500000000000 + used: + type: integer + description: Used disk space in bytes. + example: 250000000000 + free: + type: integer + description: Free disk space in bytes. + example: 250000000000 + required: + - name + - total + - used + - free + DisksResponse: + type: array + description: List of local disk usage information. + items: + $ref: '#/components/schemas/DiskResponse' + UptimeResponse: + type: object + description: System uptime information. + properties: + hostname: + type: string + description: The hostname of the agent. + uptime: + type: string + description: The uptime of the system. + example: 0 days, 4 hours, 1 minute + error: + type: string + description: Error message if the agent failed. + required: + - hostname + NodeStatusResponse: + type: object + properties: + hostname: + type: string + description: The hostname of the system. + example: my-linux-server + uptime: + type: string + description: The uptime of the system. + example: 0 days, 4 hours, 1 minute + load_average: + $ref: '#/components/schemas/LoadAverageResponse' + memory: + $ref: '#/components/schemas/MemoryResponse' + disks: + $ref: '#/components/schemas/DisksResponse' + os_info: + $ref: '#/components/schemas/OSInfoResponse' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + HostnameResponse: + type: object + description: The hostname of the system. + properties: + hostname: + type: string + description: The system's hostname. + example: my-linux-server + labels: + type: object + additionalProperties: + type: string + description: Key-value labels configured on the agent. + error: + type: string + description: Error message if the agent failed. + required: + - hostname + DiskResultItem: + type: object + properties: + hostname: + type: string + description: The hostname of the agent. + disks: + $ref: '#/components/schemas/DisksResponse' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + MemoryResultItem: + type: object + properties: + hostname: + type: string + description: The hostname of the agent. + memory: + $ref: '#/components/schemas/MemoryResponse' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + LoadResultItem: + type: object + properties: + hostname: + type: string + description: The hostname of the agent. + load_average: + $ref: '#/components/schemas/LoadAverageResponse' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + OSInfoResultItem: + type: object + properties: + hostname: + type: string + description: The hostname of the agent. + os_info: + $ref: '#/components/schemas/OSInfoResponse' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + NodeStatusCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/NodeStatusResponse' + required: + - results + HostnameCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/HostnameResponse' + required: + - results + DiskCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/DiskResultItem' + required: + - results + MemoryCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/MemoryResultItem' + required: + - results + LoadCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/LoadResultItem' + required: + - results + OSInfoCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/OSInfoResultItem' + required: + - results + UptimeCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/UptimeResponse' + required: + - results + PingResponse: + type: object + properties: + hostname: + type: string + description: The hostname of the agent that executed the ping. + packets_sent: + type: integer + description: Number of packets sent. + example: 4 + packets_received: + type: integer + description: Number of packets received. + example: 4 + packet_loss: + type: number + format: double + description: Percentage of packet loss. + example: 0 + min_rtt: + type: string + description: | + Minimum round-trip time in Go time.Duration format. + example: 14.637103ms + avg_rtt: + type: string + description: | + Average round-trip time in Go time.Duration format. + example: 18.647498ms + max_rtt: + type: string + description: | + Maximum round-trip time in Go time.Duration format. + example: 24.309240ms + error: + type: string + description: Error message if the agent failed. + required: + - hostname + PingCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/PingResponse' + required: + - results + DNSConfigResponse: + type: object + properties: + hostname: + type: string + description: The hostname of the agent that served this config. + servers: + type: array + description: List of configured DNS servers. + items: + type: string + description: IPv4 or IPv6 address of the DNS server. + search_domains: + type: array + description: List of search domains. + items: + type: string + error: + type: string + description: Error message if the agent failed. + required: + - hostname + DNSConfigCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/DNSConfigResponse' + required: + - results + DNSUpdateResultItem: + type: object + properties: + hostname: + type: string + status: + type: string + enum: + - ok + - failed + changed: + type: boolean + description: Whether the DNS configuration was actually modified. + error: + type: string + required: + - hostname + - status + DNSUpdateCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/DNSUpdateResultItem' + required: + - results + DNSConfigUpdateRequest: + type: object + properties: + servers: + type: array + x-oapi-codegen-extra-tags: + validate: required_without=SearchDomains,omitempty,dive,ip,min=1 + description: New list of DNS servers to configure. + items: + type: string + description: IPv4 or IPv6 address of the DNS server. + search_domains: + type: array + x-oapi-codegen-extra-tags: + validate: required_without=Servers,omitempty,dive,hostname,min=1 + description: New list of search domains to configure. + items: + type: string + interface_name: + type: string + x-oapi-codegen-extra-tags: + validate: required,alphanum + description: > + The name of the network interface to apply DNS configuration to. + Must only contain letters and numbers. + required: + - interface_name + CommandExecRequest: + type: object + properties: + command: + type: string + description: The executable name or path. + example: ls + x-oapi-codegen-extra-tags: + validate: required,min=1 + args: + type: array + description: Command arguments. + items: + type: string + example: + - '-la' + - /tmp + cwd: + type: string + description: Working directory for the command. + example: /tmp + timeout: + type: integer + description: Timeout in seconds (default 30, max 300). + minimum: 1 + maximum: 300 + default: 30 + x-oapi-codegen-extra-tags: + validate: omitempty,min=1,max=300 + required: + - command + CommandShellRequest: + type: object + properties: + command: + type: string + description: The full shell command string. + example: ls -la /tmp | grep log + x-oapi-codegen-extra-tags: + validate: required,min=1 + cwd: + type: string + description: Working directory for the command. + example: /tmp + timeout: + type: integer + description: Timeout in seconds (default 30, max 300). + minimum: 1 + maximum: 300 + default: 30 + x-oapi-codegen-extra-tags: + validate: omitempty,min=1,max=300 + required: + - command + CommandResultItem: + type: object + properties: + hostname: + type: string + description: The hostname of the agent that executed the command. + stdout: + type: string + description: Standard output of the command. + stderr: + type: string + description: Standard error output of the command. + exit_code: + type: integer + description: Exit code of the command. + duration_ms: + type: integer + format: int64 + description: Execution time in milliseconds. + changed: + type: boolean + description: Whether the command modified system state. + error: + type: string + description: Error message if the agent failed. + required: + - hostname + CommandResultCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/CommandResultItem' + required: + - results + parameters: + Hostname: + name: hostname + in: path + required: true + description: > + Target agent hostname, reserved routing value (_any, _all), or label + selector (key:value). + x-oapi-codegen-extra-tags: + validate: required,min=1,valid_target + schema: + type: string + minLength: 1 +x-tagGroups: + - name: Agent Management API + tags: + - Agent_Management_API_agent_operations + - name: Audit Log API + tags: + - Audit_Log_API_audit + - name: OSAPI - A CRUD API for managing Linux systems + tags: + - OSAPI_-_A_CRUD_API_for_managing_Linux_systems_info + - name: Health Check API + tags: + - Health_Check_API_health + - name: Job Management API + tags: + - Job_Management_API_job_operations + - name: Node Management API + tags: + - Node_Management_API_node_operations + - Node_Management_API_node_status + - Node_Management_API_network_operations + - Node_Management_API_dns_operations + - Node_Management_API_command_operations diff --git a/internal/api/health/health_get_integration_test.go b/internal/api/health/health_get_integration_test.go deleted file mode 100644 index a772568a2..000000000 --- a/internal/api/health/health_get_integration_test.go +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package health_test - -import ( - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/health" - healthGen "github.com/retr0h/osapi/internal/api/health/gen" - "github.com/retr0h/osapi/internal/config" -) - -type HealthGetIntegrationTestSuite struct { - suite.Suite - - appConfig config.Config - logger *slog.Logger -} - -func (suite *HealthGetIntegrationTestSuite) SetupTest() { - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *HealthGetIntegrationTestSuite) TestGetHealth() { - tests := []struct { - name string - wantCode int - wantBody string - }{ - { - name: "when liveness probe returns ok", - wantCode: http.StatusOK, - wantBody: `{"status":"ok"}`, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - checker := &health.NATSChecker{} - healthHandler := health.New(suite.logger, checker, time.Now(), "0.1.0", nil) - strictHandler := healthGen.NewStrictHandler(healthHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - healthGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest(http.MethodGet, "/health", nil) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - suite.JSONEq(tc.wantBody, rec.Body.String()) - }) - } -} - -func TestHealthGetIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(HealthGetIntegrationTestSuite)) -} diff --git a/internal/api/health/health_get_public_test.go b/internal/api/health/health_get_public_test.go index afeb15d7c..0ef6aedea 100644 --- a/internal/api/health/health_get_public_test.go +++ b/internal/api/health/health_get_public_test.go @@ -23,20 +23,27 @@ package health_test import ( "context" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "time" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" "github.com/retr0h/osapi/internal/api/health" "github.com/retr0h/osapi/internal/api/health/gen" + "github.com/retr0h/osapi/internal/config" ) type HealthGetPublicTestSuite struct { suite.Suite - handler *health.Health - ctx context.Context + handler *health.Health + ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *HealthGetPublicTestSuite) SetupTest() { @@ -48,6 +55,8 @@ func (s *HealthGetPublicTestSuite) SetupTest() { nil, ) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *HealthGetPublicTestSuite) TestGetHealth() { @@ -74,6 +83,45 @@ func (s *HealthGetPublicTestSuite) TestGetHealth() { } } +func (s *HealthGetPublicTestSuite) TestGetHealthHTTP() { + tests := []struct { + name string + wantCode int + wantBody string + }{ + { + name: "when liveness probe returns ok", + wantCode: http.StatusOK, + wantBody: `{"status":"ok"}`, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + checker := &health.NATSChecker{} + healthHandler := health.New( + s.logger, + checker, + time.Now(), + "0.1.0", + nil, + ) + strictHandler := gen.NewStrictHandler(healthHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, "/health", nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + s.JSONEq(tc.wantBody, rec.Body.String()) + }) + } +} + func TestHealthGetPublicTestSuite(t *testing.T) { suite.Run(t, new(HealthGetPublicTestSuite)) } diff --git a/internal/api/health/health_ready_get_integration_test.go b/internal/api/health/health_ready_get_integration_test.go deleted file mode 100644 index 9d38fa2ac..000000000 --- a/internal/api/health/health_ready_get_integration_test.go +++ /dev/null @@ -1,102 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package health_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/health" - healthGen "github.com/retr0h/osapi/internal/api/health/gen" - "github.com/retr0h/osapi/internal/config" -) - -type HealthReadyGetIntegrationTestSuite struct { - suite.Suite - - appConfig config.Config - logger *slog.Logger -} - -func (suite *HealthReadyGetIntegrationTestSuite) SetupTest() { - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *HealthReadyGetIntegrationTestSuite) TestGetHealthReady() { - tests := []struct { - name string - checker *health.NATSChecker - wantCode int - wantContains []string - }{ - { - name: "when all checks pass returns ready", - checker: &health.NATSChecker{ - NATSCheck: func() error { return nil }, - KVCheck: func() error { return nil }, - }, - wantCode: http.StatusOK, - wantContains: []string{`"status":"ready"`}, - }, - { - name: "when NATS check fails returns not ready", - checker: &health.NATSChecker{ - NATSCheck: func() error { return fmt.Errorf("nats not connected") }, - KVCheck: func() error { return nil }, - }, - wantCode: http.StatusServiceUnavailable, - wantContains: []string{`"status":"not_ready"`, `"error"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - healthHandler := health.New(suite.logger, tc.checker, time.Now(), "0.1.0", nil) - strictHandler := healthGen.NewStrictHandler(healthHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - healthGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest(http.MethodGet, "/health/ready", nil) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestHealthReadyGetIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(HealthReadyGetIntegrationTestSuite)) -} diff --git a/internal/api/health/health_ready_get_public_test.go b/internal/api/health/health_ready_get_public_test.go index e5013a5ef..85d08ccbd 100644 --- a/internal/api/health/health_ready_get_public_test.go +++ b/internal/api/health/health_ready_get_public_test.go @@ -24,23 +24,32 @@ import ( "context" "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "time" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" "github.com/retr0h/osapi/internal/api/health" "github.com/retr0h/osapi/internal/api/health/gen" + "github.com/retr0h/osapi/internal/config" ) type HealthReadyGetPublicTestSuite struct { suite.Suite - ctx context.Context + ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *HealthReadyGetPublicTestSuite) SetupTest() { s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *HealthReadyGetPublicTestSuite) TestGetHealthReady() { @@ -117,6 +126,60 @@ func (s *HealthReadyGetPublicTestSuite) TestGetHealthReady() { } } +func (s *HealthReadyGetPublicTestSuite) TestGetHealthReadyHTTP() { + tests := []struct { + name string + checker *health.NATSChecker + wantCode int + wantContains []string + }{ + { + name: "when all checks pass returns ready", + checker: &health.NATSChecker{ + NATSCheck: func() error { return nil }, + KVCheck: func() error { return nil }, + }, + wantCode: http.StatusOK, + wantContains: []string{`"status":"ready"`}, + }, + { + name: "when NATS check fails returns not ready", + checker: &health.NATSChecker{ + NATSCheck: func() error { return fmt.Errorf("nats not connected") }, + KVCheck: func() error { return nil }, + }, + wantCode: http.StatusServiceUnavailable, + wantContains: []string{`"status":"not_ready"`, `"error"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + healthHandler := health.New( + s.logger, + tc.checker, + time.Now(), + "0.1.0", + nil, + ) + strictHandler := gen.NewStrictHandler(healthHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, "/health/ready", nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, want := range tc.wantContains { + s.Contains(rec.Body.String(), want) + } + }) + } +} + func TestHealthReadyGetPublicTestSuite(t *testing.T) { suite.Run(t, new(HealthReadyGetPublicTestSuite)) } diff --git a/internal/api/health/health_status_get_integration_test.go b/internal/api/health/health_status_get_integration_test.go deleted file mode 100644 index 05b5be2ef..000000000 --- a/internal/api/health/health_status_get_integration_test.go +++ /dev/null @@ -1,268 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package health_test - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/health" - healthGen "github.com/retr0h/osapi/internal/api/health/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" -) - -type HealthStatusGetIntegrationTestSuite struct { - suite.Suite - - appConfig config.Config - logger *slog.Logger -} - -func (suite *HealthStatusGetIntegrationTestSuite) SetupTest() { - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *HealthStatusGetIntegrationTestSuite) TestGetHealthStatusValidation() { - tests := []struct { - name string - checker *health.NATSChecker - metrics health.MetricsProvider - wantCode int - wantContains []string - }{ - { - name: "when all components healthy returns status with metrics", - checker: &health.NATSChecker{ - NATSCheck: func() error { return nil }, - KVCheck: func() error { return nil }, - }, - metrics: &health.ClosureMetricsProvider{ - NATSInfoFn: func(_ context.Context) (*health.NATSMetrics, error) { - return &health.NATSMetrics{URL: "nats://localhost:4222", Version: "2.10.0"}, nil - }, - StreamInfoFn: func(_ context.Context) ([]health.StreamMetrics, error) { - return []health.StreamMetrics{ - {Name: "JOBS", Messages: 42, Bytes: 1024, Consumers: 1}, - }, nil - }, - KVInfoFn: func(_ context.Context) ([]health.KVMetrics, error) { - return []health.KVMetrics{ - {Name: "job-queue", Keys: 10, Bytes: 2048}, - }, nil - }, - ConsumerStatsFn: func(_ context.Context) (*health.ConsumerMetrics, error) { - return &health.ConsumerMetrics{ - Total: 2, - Consumers: []health.ConsumerDetail{ - {Name: "query_any_web_01", Pending: 0, AckPending: 3, Redelivered: 0}, - }, - }, nil - }, - JobStatsFn: func(_ context.Context) (*health.JobMetrics, error) { - return &health.JobMetrics{ - Total: 100, Unprocessed: 5, Processing: 2, - Completed: 90, Failed: 3, DLQ: 0, - }, nil - }, - AgentStatsFn: func(_ context.Context) (*health.AgentMetrics, error) { - return &health.AgentMetrics{ - Total: 3, - Ready: 3, - Agents: []health.AgentDetail{ - {Hostname: "web-01", Labels: "group=web.prod", Registered: "15s ago"}, - }, - }, nil - }, - }, - wantCode: http.StatusOK, - wantContains: []string{ - `"status":"ok"`, - `"version":"0.1.0"`, - `"uptime"`, - `"nats"`, - `"streams"`, - `"kv_buckets"`, - `"consumers"`, - `"jobs"`, - `"agents"`, - `"web-01"`, - `"group=web.prod"`, - `"query_any_web_01"`, - }, - }, - { - name: "when nil metrics omits metrics fields", - checker: &health.NATSChecker{ - NATSCheck: func() error { return nil }, - KVCheck: func() error { return nil }, - }, - metrics: nil, - wantCode: http.StatusOK, - wantContains: []string{ - `"status":"ok"`, - `"version":"0.1.0"`, - }, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - healthHandler := health.New( - suite.logger, tc.checker, time.Now(), "0.1.0", tc.metrics, - ) - strictHandler := healthGen.NewStrictHandler(healthHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - healthGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest(http.MethodGet, "/health/status", nil) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacHealthStatusTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *HealthStatusGetIntegrationTestSuite) TestGetHealthStatusRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacHealthStatusTestSigningKey, - []string{"read"}, - "test-user", - []string{"job:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with health:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacHealthStatusTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - wantCode: http.StatusOK, - wantContains: []string{`"status":"ok"`, `"version"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - checker := &health.NATSChecker{ - NATSCheck: func() error { return nil }, - KVCheck: func() error { return nil }, - } - metrics := &health.ClosureMetricsProvider{ - NATSInfoFn: func(_ context.Context) (*health.NATSMetrics, error) { - return &health.NATSMetrics{URL: "nats://localhost:4222", Version: "2.10.0"}, nil - }, - StreamInfoFn: func(_ context.Context) ([]health.StreamMetrics, error) { - return []health.StreamMetrics{}, nil - }, - KVInfoFn: func(_ context.Context) ([]health.KVMetrics, error) { - return []health.KVMetrics{}, nil - }, - JobStatsFn: func(_ context.Context) (*health.JobMetrics, error) { - return &health.JobMetrics{}, nil - }, - ConsumerStatsFn: func(_ context.Context) (*health.ConsumerMetrics, error) { - return &health.ConsumerMetrics{}, nil - }, - AgentStatsFn: func(_ context.Context) (*health.AgentMetrics, error) { - return &health.AgentMetrics{}, nil - }, - } - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacHealthStatusTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetHealthHandler(checker, time.Now(), "0.1.0", metrics) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest(http.MethodGet, "/health/status", nil) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestHealthStatusGetIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(HealthStatusGetIntegrationTestSuite)) -} diff --git a/internal/api/health/health_status_get_public_test.go b/internal/api/health/health_status_get_public_test.go index b13e5aa73..f585cfc01 100644 --- a/internal/api/health/health_status_get_public_test.go +++ b/internal/api/health/health_status_get_public_test.go @@ -24,13 +24,19 @@ import ( "context" "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "time" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" "github.com/retr0h/osapi/internal/api/health" "github.com/retr0h/osapi/internal/api/health/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" ) type stubChecker struct{} @@ -41,14 +47,20 @@ func (s *stubChecker) CheckHealth( return nil } +const rbacHealthStatusTestSigningKey = "test-signing-key-for-rbac-integration" + type HealthStatusGetPublicTestSuite struct { suite.Suite - ctx context.Context + ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *HealthStatusGetPublicTestSuite) SetupTest() { s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *HealthStatusGetPublicTestSuite) TestGetHealthStatus() { @@ -346,6 +358,255 @@ func (s *HealthStatusGetPublicTestSuite) TestGetHealthStatus() { } } +func (s *HealthStatusGetPublicTestSuite) TestGetHealthStatusHTTP() { + tests := []struct { + name string + checker *health.NATSChecker + metrics health.MetricsProvider + wantCode int + wantContains []string + }{ + { + name: "when all components healthy returns status with metrics", + checker: &health.NATSChecker{ + NATSCheck: func() error { return nil }, + KVCheck: func() error { return nil }, + }, + metrics: &health.ClosureMetricsProvider{ + NATSInfoFn: func( + _ context.Context, + ) (*health.NATSMetrics, error) { + return &health.NATSMetrics{ + URL: "nats://localhost:4222", + Version: "2.10.0", + }, nil + }, + StreamInfoFn: func( + _ context.Context, + ) ([]health.StreamMetrics, error) { + return []health.StreamMetrics{ + {Name: "JOBS", Messages: 42, Bytes: 1024, Consumers: 1}, + }, nil + }, + KVInfoFn: func( + _ context.Context, + ) ([]health.KVMetrics, error) { + return []health.KVMetrics{ + {Name: "job-queue", Keys: 10, Bytes: 2048}, + }, nil + }, + ConsumerStatsFn: func( + _ context.Context, + ) (*health.ConsumerMetrics, error) { + return &health.ConsumerMetrics{ + Total: 2, + Consumers: []health.ConsumerDetail{ + {Name: "query_any_web_01", Pending: 0, AckPending: 3, Redelivered: 0}, + }, + }, nil + }, + JobStatsFn: func( + _ context.Context, + ) (*health.JobMetrics, error) { + return &health.JobMetrics{ + Total: 100, Unprocessed: 5, Processing: 2, + Completed: 90, Failed: 3, DLQ: 0, + }, nil + }, + AgentStatsFn: func( + _ context.Context, + ) (*health.AgentMetrics, error) { + return &health.AgentMetrics{ + Total: 3, + Ready: 3, + Agents: []health.AgentDetail{ + {Hostname: "web-01", Labels: "group=web.prod", Registered: "15s ago"}, + }, + }, nil + }, + }, + wantCode: http.StatusOK, + wantContains: []string{ + `"status":"ok"`, + `"version":"0.1.0"`, + `"uptime"`, + `"nats"`, + `"streams"`, + `"kv_buckets"`, + `"consumers"`, + `"jobs"`, + `"agents"`, + `"web-01"`, + `"group=web.prod"`, + `"query_any_web_01"`, + }, + }, + { + name: "when nil metrics omits metrics fields", + checker: &health.NATSChecker{ + NATSCheck: func() error { return nil }, + KVCheck: func() error { return nil }, + }, + metrics: nil, + wantCode: http.StatusOK, + wantContains: []string{ + `"status":"ok"`, + `"version":"0.1.0"`, + }, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + healthHandler := health.New( + s.logger, + tc.checker, + time.Now(), + "0.1.0", + tc.metrics, + ) + strictHandler := gen.NewStrictHandler(healthHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, "/health/status", nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, want := range tc.wantContains { + s.Contains(rec.Body.String(), want) + } + }) + } +} + +func (s *HealthStatusGetPublicTestSuite) TestGetHealthStatusRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacHealthStatusTestSigningKey, + []string{"read"}, + "test-user", + []string{"job:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with health:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacHealthStatusTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + wantCode: http.StatusOK, + wantContains: []string{`"status":"ok"`, `"version"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + checker := &health.NATSChecker{ + NATSCheck: func() error { return nil }, + KVCheck: func() error { return nil }, + } + metrics := &health.ClosureMetricsProvider{ + NATSInfoFn: func( + _ context.Context, + ) (*health.NATSMetrics, error) { + return &health.NATSMetrics{ + URL: "nats://localhost:4222", + Version: "2.10.0", + }, nil + }, + StreamInfoFn: func( + _ context.Context, + ) ([]health.StreamMetrics, error) { + return []health.StreamMetrics{}, nil + }, + KVInfoFn: func( + _ context.Context, + ) ([]health.KVMetrics, error) { + return []health.KVMetrics{}, nil + }, + JobStatsFn: func( + _ context.Context, + ) (*health.JobMetrics, error) { + return &health.JobMetrics{}, nil + }, + ConsumerStatsFn: func( + _ context.Context, + ) (*health.ConsumerMetrics, error) { + return &health.ConsumerMetrics{}, nil + }, + AgentStatsFn: func( + _ context.Context, + ) (*health.AgentMetrics, error) { + return &health.AgentMetrics{}, nil + }, + } + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacHealthStatusTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetHealthHandler( + checker, + time.Now(), + "0.1.0", + metrics, + ) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest(http.MethodGet, "/health/status", nil) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, want := range tc.wantContains { + s.Contains(rec.Body.String(), want) + } + }) + } +} + func TestHealthStatusGetPublicTestSuite(t *testing.T) { suite.Run(t, new(HealthStatusGetPublicTestSuite)) } diff --git a/internal/api/job/job_create_integration_test.go b/internal/api/job/job_create_integration_test.go deleted file mode 100644 index 3de9d7a3f..000000000 --- a/internal/api/job/job_create_integration_test.go +++ /dev/null @@ -1,249 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package job_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - apijob "github.com/retr0h/osapi/internal/api/job" - jobGen "github.com/retr0h/osapi/internal/api/job/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - "github.com/retr0h/osapi/internal/job/client" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" -) - -type JobCreateIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *JobCreateIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *JobCreateIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *JobCreateIntegrationTestSuite) TestPostJobValidation() { - tests := []struct { - name string - body string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid request", - body: `{"operation":{"type":"node.hostname.get"},"target_hostname":"_any"}`, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - CreateJob(gomock.Any(), gomock.Any(), "_any"). - Return(&client.CreateJobResult{ - JobID: "550e8400-e29b-41d4-a716-446655440000", - Status: "created", - Revision: 1, - Timestamp: "2025-06-14T10:00:00Z", - }, nil) - return mock - }, - wantCode: http.StatusCreated, - wantContains: []string{ - `"job_id":"550e8400-e29b-41d4-a716-446655440000"`, - `"status":"created"`, - }, - }, - { - name: "when missing operation", - body: `{"target_hostname":"_any"}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "Operation", "required"}, - }, - { - name: "when empty target hostname", - body: `{"operation":{"type":"test"}}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "TargetHostname", "required"}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - jobHandler := apijob.New(suite.logger, jobMock) - strictHandler := jobGen.NewStrictHandler(jobHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - jobGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodPost, - "/job", - strings.NewReader(tc.body), - ) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacJobCreateTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *JobCreateIntegrationTestSuite) TestPostJobRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobCreateTestSigningKey, - []string{"read"}, - "test-user", - []string{"job:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with job:write returns 201", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobCreateTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - CreateJob(gomock.Any(), gomock.Any(), "_any"). - Return(&client.CreateJobResult{ - JobID: "550e8400-e29b-41d4-a716-446655440000", - Status: "created", - Revision: 1, - Timestamp: "2025-06-14T10:00:00Z", - }, nil) - return mock - }, - wantCode: http.StatusCreated, - wantContains: []string{`"job_id":"550e8400-e29b-41d4-a716-446655440000"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacJobCreateTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetJobHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodPost, - "/job", - strings.NewReader(`{"operation":{"type":"test"},"target_hostname":"_any"}`), - ) - req.Header.Set("Content-Type", "application/json") - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestJobCreateIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(JobCreateIntegrationTestSuite)) -} diff --git a/internal/api/job/job_create_public_test.go b/internal/api/job/job_create_public_test.go index 1b7d274ff..8c77b46ef 100644 --- a/internal/api/job/job_create_public_test.go +++ b/internal/api/job/job_create_public_test.go @@ -22,15 +22,23 @@ package job_test import ( "context" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apijob "github.com/retr0h/osapi/internal/api/job" "github.com/retr0h/osapi/internal/api/job/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" "github.com/retr0h/osapi/internal/job/client" jobmocks "github.com/retr0h/osapi/internal/job/mocks" ) @@ -42,6 +50,8 @@ type JobCreatePublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apijob.Job ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *JobCreatePublicTestSuite) SetupTest() { @@ -49,6 +59,8 @@ func (s *JobCreatePublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apijob.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *JobCreatePublicTestSuite) TearDownTest() { @@ -150,6 +162,190 @@ func (s *JobCreatePublicTestSuite) TestPostJob() { } } +func (s *JobCreatePublicTestSuite) TestPostJobHTTP() { + tests := []struct { + name string + body string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + body: `{"operation":{"type":"node.hostname.get"},"target_hostname":"_any"}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + CreateJob(gomock.Any(), gomock.Any(), "_any"). + Return(&client.CreateJobResult{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Status: "created", + Revision: 1, + Timestamp: "2025-06-14T10:00:00Z", + }, nil) + return mock + }, + wantCode: http.StatusCreated, + wantContains: []string{ + `"job_id":"550e8400-e29b-41d4-a716-446655440000"`, + `"status":"created"`, + }, + }, + { + name: "when missing operation", + body: `{"target_hostname":"_any"}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "Operation", "required"}, + }, + { + name: "when empty target hostname", + body: `{"operation":{"type":"test"}}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "TargetHostname", "required"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + jobHandler := apijob.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(jobHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodPost, + "/job", + strings.NewReader(tc.body), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacJobCreateTestSigningKey = "test-signing-key-for-rbac-integration" + +func (s *JobCreatePublicTestSuite) TestPostJobRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobCreateTestSigningKey, + []string{"read"}, + "test-user", + []string{"job:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with job:write returns 201", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobCreateTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + CreateJob(gomock.Any(), gomock.Any(), "_any"). + Return(&client.CreateJobResult{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Status: "created", + Revision: 1, + Timestamp: "2025-06-14T10:00:00Z", + }, nil) + return mock + }, + wantCode: http.StatusCreated, + wantContains: []string{`"job_id":"550e8400-e29b-41d4-a716-446655440000"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacJobCreateTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetJobHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodPost, + "/job", + strings.NewReader(`{"operation":{"type":"test"},"target_hostname":"_any"}`), + ) + req.Header.Set("Content-Type", "application/json") + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestJobCreatePublicTestSuite(t *testing.T) { suite.Run(t, new(JobCreatePublicTestSuite)) } diff --git a/internal/api/job/job_delete_integration_test.go b/internal/api/job/job_delete_integration_test.go deleted file mode 100644 index 968e6f00c..000000000 --- a/internal/api/job/job_delete_integration_test.go +++ /dev/null @@ -1,221 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package job_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - apijob "github.com/retr0h/osapi/internal/api/job" - jobGen "github.com/retr0h/osapi/internal/api/job/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" -) - -type JobDeleteIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *JobDeleteIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *JobDeleteIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *JobDeleteIntegrationTestSuite) TestDeleteJobByIDValidation() { - tests := []struct { - name string - jobID string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid uuid", - jobID: "550e8400-e29b-41d4-a716-446655440000", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - DeleteJob(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000"). - Return(nil) - return mock - }, - wantCode: http.StatusNoContent, - }, - { - name: "when invalid uuid", - jobID: "not-a-uuid", - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"message"`, "Invalid format for parameter id"}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - jobHandler := apijob.New(suite.logger, jobMock) - strictHandler := jobGen.NewStrictHandler(jobHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - jobGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodDelete, - "/job/"+tc.jobID, - nil, - ) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacJobDeleteTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *JobDeleteIntegrationTestSuite) TestDeleteJobByIDRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobDeleteTestSigningKey, - []string{"read"}, - "test-user", - []string{"job:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with job:write returns 204", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobDeleteTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - DeleteJob(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000"). - Return(nil) - return mock - }, - wantCode: http.StatusNoContent, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacJobDeleteTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetJobHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodDelete, - "/job/550e8400-e29b-41d4-a716-446655440000", - nil, - ) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestJobDeleteIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(JobDeleteIntegrationTestSuite)) -} diff --git a/internal/api/job/job_delete_public_test.go b/internal/api/job/job_delete_public_test.go index ca88710a8..e0de9ba0f 100644 --- a/internal/api/job/job_delete_public_test.go +++ b/internal/api/job/job_delete_public_test.go @@ -24,6 +24,9 @@ import ( "context" "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/golang/mock/gomock" @@ -31,8 +34,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apijob "github.com/retr0h/osapi/internal/api/job" "github.com/retr0h/osapi/internal/api/job/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobmocks "github.com/retr0h/osapi/internal/job/mocks" ) @@ -43,6 +49,8 @@ type JobDeletePublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apijob.Job ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *JobDeletePublicTestSuite) SetupTest() { @@ -50,6 +58,8 @@ func (s *JobDeletePublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apijob.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *JobDeletePublicTestSuite) TearDownTest() { @@ -116,6 +126,164 @@ func (s *JobDeletePublicTestSuite) TestDeleteJobByID() { } } +func (s *JobDeletePublicTestSuite) TestDeleteJobByIDHTTP() { + tests := []struct { + name string + jobID string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid uuid", + jobID: "550e8400-e29b-41d4-a716-446655440000", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + DeleteJob(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000"). + Return(nil) + return mock + }, + wantCode: http.StatusNoContent, + }, + { + name: "when invalid uuid", + jobID: "not-a-uuid", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"message"`, "Invalid format for parameter id"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + jobHandler := apijob.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(jobHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodDelete, + "/job/"+tc.jobID, + nil, + ) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacJobDeleteTestSigningKey = "test-signing-key-for-rbac-integration" + +func (s *JobDeletePublicTestSuite) TestDeleteJobByIDRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobDeleteTestSigningKey, + []string{"read"}, + "test-user", + []string{"job:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with job:write returns 204", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobDeleteTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + DeleteJob(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000"). + Return(nil) + return mock + }, + wantCode: http.StatusNoContent, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacJobDeleteTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetJobHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodDelete, + "/job/550e8400-e29b-41d4-a716-446655440000", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestJobDeletePublicTestSuite(t *testing.T) { suite.Run(t, new(JobDeletePublicTestSuite)) } diff --git a/internal/api/job/job_get_integration_test.go b/internal/api/job/job_get_integration_test.go deleted file mode 100644 index afbac4681..000000000 --- a/internal/api/job/job_get_integration_test.go +++ /dev/null @@ -1,274 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package job_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - apijob "github.com/retr0h/osapi/internal/api/job" - jobGen "github.com/retr0h/osapi/internal/api/job/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobtypes "github.com/retr0h/osapi/internal/job" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" -) - -type JobGetIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *JobGetIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *JobGetIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *JobGetIntegrationTestSuite) TestGetJobByIDValidation() { - tests := []struct { - name string - jobID string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid uuid", - jobID: "550e8400-e29b-41d4-a716-446655440000", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - GetJobStatus(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000"). - Return(&jobtypes.QueuedJob{ - ID: "550e8400-e29b-41d4-a716-446655440000", - Status: "completed", - Created: "2026-02-19T00:00:00Z", - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{ - `"id":"550e8400-e29b-41d4-a716-446655440000"`, - `"status":"completed"`, - }, - }, - { - name: "when invalid uuid", - jobID: "not-a-uuid", - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"message"`, "Invalid format for parameter id"}, - }, - { - name: "when job has timeline events", - jobID: "660e8400-e29b-41d4-a716-446655440000", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - GetJobStatus(gomock.Any(), "660e8400-e29b-41d4-a716-446655440000"). - Return(&jobtypes.QueuedJob{ - ID: "660e8400-e29b-41d4-a716-446655440000", - Status: "failed", - Created: "2026-02-19T10:00:00Z", - Timeline: []jobtypes.TimelineEvent{ - { - Timestamp: time.Date(2026, 2, 19, 10, 0, 0, 0, time.UTC), - Event: "submitted", - Hostname: "_api", - Message: "Job submitted to queue", - }, - { - Timestamp: time.Date(2026, 2, 19, 10, 0, 3, 0, time.UTC), - Event: "failed", - Hostname: "agent-1", - Message: "Job failed on agent-1", - Error: "timeout", - }, - }, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{ - `"timeline"`, - `"submitted"`, - `"failed"`, - `"Job submitted to queue"`, - `"timeout"`, - }, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - jobHandler := apijob.New(suite.logger, jobMock) - strictHandler := jobGen.NewStrictHandler(jobHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - jobGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodGet, - "/job/"+tc.jobID, - nil, - ) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacJobGetTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *JobGetIntegrationTestSuite) TestGetJobByIDRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobGetTestSigningKey, - []string{"read"}, - "test-user", - []string{"network:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with job:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobGetTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - GetJobStatus(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000"). - Return(&jobtypes.QueuedJob{ - ID: "550e8400-e29b-41d4-a716-446655440000", - Status: "completed", - Created: "2026-02-19T00:00:00Z", - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"id":"550e8400-e29b-41d4-a716-446655440000"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacJobGetTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetJobHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodGet, - "/job/550e8400-e29b-41d4-a716-446655440000", - nil, - ) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestJobGetIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(JobGetIntegrationTestSuite)) -} diff --git a/internal/api/job/job_get_public_test.go b/internal/api/job/job_get_public_test.go index 44744ce22..484d880d5 100644 --- a/internal/api/job/job_get_public_test.go +++ b/internal/api/job/job_get_public_test.go @@ -25,6 +25,9 @@ import ( "encoding/json" "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "time" @@ -33,8 +36,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apijob "github.com/retr0h/osapi/internal/api/job" "github.com/retr0h/osapi/internal/api/job/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobtypes "github.com/retr0h/osapi/internal/job" jobmocks "github.com/retr0h/osapi/internal/job/mocks" ) @@ -46,6 +52,8 @@ type JobGetPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apijob.Job ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *JobGetPublicTestSuite) SetupTest() { @@ -53,6 +61,8 @@ func (s *JobGetPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apijob.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *JobGetPublicTestSuite) TearDownTest() { @@ -359,6 +369,215 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { } } +func (s *JobGetPublicTestSuite) TestGetJobByIDHTTP() { + tests := []struct { + name string + jobID string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid uuid", + jobID: "550e8400-e29b-41d4-a716-446655440000", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + GetJobStatus(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000"). + Return(&jobtypes.QueuedJob{ + ID: "550e8400-e29b-41d4-a716-446655440000", + Status: "completed", + Created: "2026-02-19T00:00:00Z", + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{ + `"id":"550e8400-e29b-41d4-a716-446655440000"`, + `"status":"completed"`, + }, + }, + { + name: "when invalid uuid", + jobID: "not-a-uuid", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"message"`, "Invalid format for parameter id"}, + }, + { + name: "when job has timeline events", + jobID: "660e8400-e29b-41d4-a716-446655440000", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + GetJobStatus(gomock.Any(), "660e8400-e29b-41d4-a716-446655440000"). + Return(&jobtypes.QueuedJob{ + ID: "660e8400-e29b-41d4-a716-446655440000", + Status: "failed", + Created: "2026-02-19T10:00:00Z", + Timeline: []jobtypes.TimelineEvent{ + { + Timestamp: time.Date(2026, 2, 19, 10, 0, 0, 0, time.UTC), + Event: "submitted", + Hostname: "_api", + Message: "Job submitted to queue", + }, + { + Timestamp: time.Date(2026, 2, 19, 10, 0, 3, 0, time.UTC), + Event: "failed", + Hostname: "agent-1", + Message: "Job failed on agent-1", + Error: "timeout", + }, + }, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{ + `"timeline"`, + `"submitted"`, + `"failed"`, + `"Job submitted to queue"`, + `"timeout"`, + }, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + jobHandler := apijob.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(jobHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodGet, + "/job/"+tc.jobID, + nil, + ) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacJobGetTestSigningKey = "test-signing-key-for-rbac-integration" + +func (s *JobGetPublicTestSuite) TestGetJobByIDRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobGetTestSigningKey, + []string{"read"}, + "test-user", + []string{"network:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with job:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobGetTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + GetJobStatus(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000"). + Return(&jobtypes.QueuedJob{ + ID: "550e8400-e29b-41d4-a716-446655440000", + Status: "completed", + Created: "2026-02-19T00:00:00Z", + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"id":"550e8400-e29b-41d4-a716-446655440000"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacJobGetTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetJobHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/job/550e8400-e29b-41d4-a716-446655440000", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestJobGetPublicTestSuite(t *testing.T) { suite.Run(t, new(JobGetPublicTestSuite)) } diff --git a/internal/api/job/job_list_integration_test.go b/internal/api/job/job_list_integration_test.go deleted file mode 100644 index 21a92614b..000000000 --- a/internal/api/job/job_list_integration_test.go +++ /dev/null @@ -1,285 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package job_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - apijob "github.com/retr0h/osapi/internal/api/job" - jobGen "github.com/retr0h/osapi/internal/api/job/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobtypes "github.com/retr0h/osapi/internal/job" - jobclient "github.com/retr0h/osapi/internal/job/client" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" -) - -type JobListIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *JobListIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *JobListIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *JobListIntegrationTestSuite) TestListJobsValidation() { - tests := []struct { - name string - query string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid request without filter", - query: "", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ListJobs(gomock.Any(), "", 10, 0). - Return(&jobclient.ListJobsResult{ - Jobs: []*jobtypes.QueuedJob{ - {ID: "550e8400-e29b-41d4-a716-446655440000", Status: "completed"}, - }, - TotalCount: 1, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_items":1`}, - }, - { - name: "when valid status filter", - query: "?status=completed", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ListJobs(gomock.Any(), "completed", 10, 0). - Return(&jobclient.ListJobsResult{ - Jobs: []*jobtypes.QueuedJob{}, - TotalCount: 0, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_items":0`}, - }, - { - name: "when invalid status filter", - query: "?status=bogus", - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "'oneof'"}, - }, - { - name: "when negative limit returns 400", - query: "?limit=-1", - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`}, - }, - { - name: "when negative offset returns 400", - query: "?offset=-1", - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`}, - }, - { - name: "when valid limit and offset", - query: "?limit=5&offset=10", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ListJobs(gomock.Any(), "", 5, 10). - Return(&jobclient.ListJobsResult{ - Jobs: []*jobtypes.QueuedJob{}, - TotalCount: 50, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_items":50`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - jobHandler := apijob.New(suite.logger, jobMock) - strictHandler := jobGen.NewStrictHandler(jobHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - jobGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodGet, - "/job"+tc.query, - nil, - ) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacJobListTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *JobListIntegrationTestSuite) TestListJobsRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobListTestSigningKey, - []string{"read"}, - "test-user", - []string{"network:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with job:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobListTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ListJobs(gomock.Any(), "", 10, 0). - Return(&jobclient.ListJobsResult{ - Jobs: []*jobtypes.QueuedJob{ - {ID: "550e8400-e29b-41d4-a716-446655440000", Status: "completed"}, - }, - TotalCount: 1, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_items":1`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacJobListTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetJobHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodGet, - "/job", - nil, - ) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestJobListIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(JobListIntegrationTestSuite)) -} diff --git a/internal/api/job/job_list_public_test.go b/internal/api/job/job_list_public_test.go index 0de642764..be0ed87f9 100644 --- a/internal/api/job/job_list_public_test.go +++ b/internal/api/job/job_list_public_test.go @@ -23,15 +23,22 @@ package job_test import ( "context" "encoding/json" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apijob "github.com/retr0h/osapi/internal/api/job" "github.com/retr0h/osapi/internal/api/job/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobtypes "github.com/retr0h/osapi/internal/job" jobclient "github.com/retr0h/osapi/internal/job/client" jobmocks "github.com/retr0h/osapi/internal/job/mocks" @@ -44,6 +51,8 @@ type JobListPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apijob.Job ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *JobListPublicTestSuite) SetupTest() { @@ -51,6 +60,8 @@ func (s *JobListPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apijob.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *JobListPublicTestSuite) TearDownTest() { @@ -261,6 +272,226 @@ func (s *JobListPublicTestSuite) TestGetJob() { } } +func (s *JobListPublicTestSuite) TestListJobsHTTP() { + tests := []struct { + name string + query string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request without filter", + query: "", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ListJobs(gomock.Any(), "", 10, 0). + Return(&jobclient.ListJobsResult{ + Jobs: []*jobtypes.QueuedJob{ + {ID: "550e8400-e29b-41d4-a716-446655440000", Status: "completed"}, + }, + TotalCount: 1, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_items":1`}, + }, + { + name: "when valid status filter", + query: "?status=completed", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ListJobs(gomock.Any(), "completed", 10, 0). + Return(&jobclient.ListJobsResult{ + Jobs: []*jobtypes.QueuedJob{}, + TotalCount: 0, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_items":0`}, + }, + { + name: "when invalid status filter", + query: "?status=bogus", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "'oneof'"}, + }, + { + name: "when negative limit returns 400", + query: "?limit=-1", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`}, + }, + { + name: "when negative offset returns 400", + query: "?offset=-1", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`}, + }, + { + name: "when valid limit and offset", + query: "?limit=5&offset=10", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ListJobs(gomock.Any(), "", 5, 10). + Return(&jobclient.ListJobsResult{ + Jobs: []*jobtypes.QueuedJob{}, + TotalCount: 50, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_items":50`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + jobHandler := apijob.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(jobHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodGet, + "/job"+tc.query, + nil, + ) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacJobListTestSigningKey = "test-signing-key-for-rbac-integration" + +func (s *JobListPublicTestSuite) TestListJobsRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobListTestSigningKey, + []string{"read"}, + "test-user", + []string{"network:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with job:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobListTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ListJobs(gomock.Any(), "", 10, 0). + Return(&jobclient.ListJobsResult{ + Jobs: []*jobtypes.QueuedJob{ + {ID: "550e8400-e29b-41d4-a716-446655440000", Status: "completed"}, + }, + TotalCount: 1, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_items":1`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacJobListTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetJobHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/job", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestJobListPublicTestSuite(t *testing.T) { suite.Run(t, new(JobListPublicTestSuite)) } diff --git a/internal/api/job/job_retry_integration_test.go b/internal/api/job/job_retry_integration_test.go deleted file mode 100644 index 10943e979..000000000 --- a/internal/api/job/job_retry_integration_test.go +++ /dev/null @@ -1,272 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package job_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - apijob "github.com/retr0h/osapi/internal/api/job" - jobGen "github.com/retr0h/osapi/internal/api/job/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - "github.com/retr0h/osapi/internal/job/client" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" -) - -type JobRetryIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *JobRetryIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *JobRetryIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *JobRetryIntegrationTestSuite) TestRetryJobByIDValidation() { - tests := []struct { - name string - jobID string - body string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid request with target", - jobID: "550e8400-e29b-41d4-a716-446655440000", - body: `{"target_hostname":"_any"}`, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - RetryJob(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000", "_any"). - Return(&client.CreateJobResult{ - JobID: "660e8400-e29b-41d4-a716-446655440000", - Status: "created", - Revision: 1, - Timestamp: "2026-02-19T00:00:00Z", - }, nil) - return mock - }, - wantCode: http.StatusCreated, - wantContains: []string{ - `"job_id":"660e8400-e29b-41d4-a716-446655440000"`, - `"status":"created"`, - }, - }, - { - name: "when valid request without body", - jobID: "550e8400-e29b-41d4-a716-446655440000", - body: `{}`, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - RetryJob(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000", ""). - Return(&client.CreateJobResult{ - JobID: "770e8400-e29b-41d4-a716-446655440000", - Status: "created", - Revision: 1, - Timestamp: "2026-02-19T00:00:00Z", - }, nil) - return mock - }, - wantCode: http.StatusCreated, - wantContains: []string{`"job_id":"770e8400-e29b-41d4-a716-446655440000"`}, - }, - { - name: "when invalid uuid", - jobID: "not-a-uuid", - body: `{}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"message"`, "Invalid format for parameter id"}, - }, - { - name: "when empty target hostname in body", - jobID: "550e8400-e29b-41d4-a716-446655440000", - body: `{"target_hostname":""}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "TargetHostname"}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - jobHandler := apijob.New(suite.logger, jobMock) - strictHandler := jobGen.NewStrictHandler(jobHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - jobGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodPost, - "/job/"+tc.jobID+"/retry", - strings.NewReader(tc.body), - ) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacJobRetryTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *JobRetryIntegrationTestSuite) TestRetryJobByIDRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobRetryTestSigningKey, - []string{"read"}, - "test-user", - []string{"job:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with job:write returns 201", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobRetryTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - RetryJob(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000", "_any"). - Return(&client.CreateJobResult{ - JobID: "660e8400-e29b-41d4-a716-446655440000", - Status: "created", - Revision: 1, - Timestamp: "2026-02-19T00:00:00Z", - }, nil) - return mock - }, - wantCode: http.StatusCreated, - wantContains: []string{`"job_id":"660e8400-e29b-41d4-a716-446655440000"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacJobRetryTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetJobHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodPost, - "/job/550e8400-e29b-41d4-a716-446655440000/retry", - strings.NewReader(`{"target_hostname":"_any"}`), - ) - req.Header.Set("Content-Type", "application/json") - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestJobRetryIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(JobRetryIntegrationTestSuite)) -} diff --git a/internal/api/job/job_retry_public_test.go b/internal/api/job/job_retry_public_test.go index b1b0c2ddb..baa1f2940 100644 --- a/internal/api/job/job_retry_public_test.go +++ b/internal/api/job/job_retry_public_test.go @@ -24,14 +24,21 @@ import ( "context" "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" "testing" "github.com/golang/mock/gomock" "github.com/google/uuid" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apijob "github.com/retr0h/osapi/internal/api/job" "github.com/retr0h/osapi/internal/api/job/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" "github.com/retr0h/osapi/internal/job/client" jobmocks "github.com/retr0h/osapi/internal/job/mocks" ) @@ -43,6 +50,8 @@ type JobRetryPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apijob.Job ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *JobRetryPublicTestSuite) SetupTest() { @@ -50,6 +59,8 @@ func (s *JobRetryPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apijob.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *JobRetryPublicTestSuite) TearDownTest() { @@ -187,6 +198,213 @@ func (s *JobRetryPublicTestSuite) TestRetryJobByID() { } } +func (s *JobRetryPublicTestSuite) TestRetryJobByIDHTTP() { + tests := []struct { + name string + jobID string + body string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request with target", + jobID: "550e8400-e29b-41d4-a716-446655440000", + body: `{"target_hostname":"_any"}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + RetryJob(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000", "_any"). + Return(&client.CreateJobResult{ + JobID: "660e8400-e29b-41d4-a716-446655440000", + Status: "created", + Revision: 1, + Timestamp: "2026-02-19T00:00:00Z", + }, nil) + return mock + }, + wantCode: http.StatusCreated, + wantContains: []string{ + `"job_id":"660e8400-e29b-41d4-a716-446655440000"`, + `"status":"created"`, + }, + }, + { + name: "when valid request without body", + jobID: "550e8400-e29b-41d4-a716-446655440000", + body: `{}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + RetryJob(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000", ""). + Return(&client.CreateJobResult{ + JobID: "770e8400-e29b-41d4-a716-446655440000", + Status: "created", + Revision: 1, + Timestamp: "2026-02-19T00:00:00Z", + }, nil) + return mock + }, + wantCode: http.StatusCreated, + wantContains: []string{`"job_id":"770e8400-e29b-41d4-a716-446655440000"`}, + }, + { + name: "when invalid uuid", + jobID: "not-a-uuid", + body: `{}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"message"`, "Invalid format for parameter id"}, + }, + { + name: "when empty target hostname in body", + jobID: "550e8400-e29b-41d4-a716-446655440000", + body: `{"target_hostname":""}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "TargetHostname"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + jobHandler := apijob.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(jobHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodPost, + "/job/"+tc.jobID+"/retry", + strings.NewReader(tc.body), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacJobRetryTestSigningKey = "test-signing-key-for-rbac-integration" + +func (s *JobRetryPublicTestSuite) TestRetryJobByIDRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobRetryTestSigningKey, + []string{"read"}, + "test-user", + []string{"job:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with job:write returns 201", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobRetryTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + RetryJob(gomock.Any(), "550e8400-e29b-41d4-a716-446655440000", "_any"). + Return(&client.CreateJobResult{ + JobID: "660e8400-e29b-41d4-a716-446655440000", + Status: "created", + Revision: 1, + Timestamp: "2026-02-19T00:00:00Z", + }, nil) + return mock + }, + wantCode: http.StatusCreated, + wantContains: []string{`"job_id":"660e8400-e29b-41d4-a716-446655440000"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacJobRetryTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetJobHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodPost, + "/job/550e8400-e29b-41d4-a716-446655440000/retry", + strings.NewReader(`{"target_hostname":"_any"}`), + ) + req.Header.Set("Content-Type", "application/json") + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestJobRetryPublicTestSuite(t *testing.T) { suite.Run(t, new(JobRetryPublicTestSuite)) } diff --git a/internal/api/job/job_status_integration_test.go b/internal/api/job/job_status_integration_test.go deleted file mode 100644 index be65ded81..000000000 --- a/internal/api/job/job_status_integration_test.go +++ /dev/null @@ -1,242 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package job_test - -import ( - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - apijob "github.com/retr0h/osapi/internal/api/job" - jobGen "github.com/retr0h/osapi/internal/api/job/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobtypes "github.com/retr0h/osapi/internal/job" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" -) - -type JobStatusIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *JobStatusIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *JobStatusIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *JobStatusIntegrationTestSuite) TestGetJobStatusValidation() { - tests := []struct { - name string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid request returns queue stats", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - GetQueueStats(gomock.Any()). - Return(&jobtypes.QueueStats{ - TotalJobs: 42, - StatusCounts: map[string]int{ - "completed": 30, - "failed": 5, - }, - OperationCounts: map[string]int{ - "node.hostname.get": 15, - }, - DLQCount: 2, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_jobs":42`, `"dlq_count":2`}, - }, - { - name: "when job client errors returns 500", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - GetQueueStats(gomock.Any()). - Return(nil, assert.AnError) - return mock - }, - wantCode: http.StatusInternalServerError, - wantContains: []string{`"error"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - jobHandler := apijob.New(suite.logger, jobMock) - strictHandler := jobGen.NewStrictHandler(jobHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - jobGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest(http.MethodGet, "/job/status", nil) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacJobStatusTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *JobStatusIntegrationTestSuite) TestGetJobStatusRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobStatusTestSigningKey, - []string{"read"}, - "test-user", - []string{"network:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with job:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacJobStatusTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - GetQueueStats(gomock.Any()). - Return(&jobtypes.QueueStats{ - TotalJobs: 42, - StatusCounts: map[string]int{ - "completed": 30, - "failed": 5, - }, - OperationCounts: map[string]int{ - "node.hostname.get": 15, - }, - DLQCount: 2, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"total_jobs":42`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacJobStatusTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetJobHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodGet, - "/job/status", - nil, - ) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestJobStatusIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(JobStatusIntegrationTestSuite)) -} diff --git a/internal/api/job/job_status_public_test.go b/internal/api/job/job_status_public_test.go index 05399052f..4f98ab263 100644 --- a/internal/api/job/job_status_public_test.go +++ b/internal/api/job/job_status_public_test.go @@ -22,15 +22,22 @@ package job_test import ( "context" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apijob "github.com/retr0h/osapi/internal/api/job" "github.com/retr0h/osapi/internal/api/job/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobtypes "github.com/retr0h/osapi/internal/job" jobmocks "github.com/retr0h/osapi/internal/job/mocks" ) @@ -42,6 +49,8 @@ type JobStatusPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apijob.Job ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *JobStatusPublicTestSuite) SetupTest() { @@ -49,6 +58,8 @@ func (s *JobStatusPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apijob.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *JobStatusPublicTestSuite) TearDownTest() { @@ -105,6 +116,183 @@ func (s *JobStatusPublicTestSuite) TestGetJobStatus() { } } +func (s *JobStatusPublicTestSuite) TestGetJobStatusHTTP() { + tests := []struct { + name string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request returns queue stats", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + GetQueueStats(gomock.Any()). + Return(&jobtypes.QueueStats{ + TotalJobs: 42, + StatusCounts: map[string]int{ + "completed": 30, + "failed": 5, + }, + OperationCounts: map[string]int{ + "node.hostname.get": 15, + }, + DLQCount: 2, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_jobs":42`, `"dlq_count":2`}, + }, + { + name: "when job client errors returns 500", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + GetQueueStats(gomock.Any()). + Return(nil, assert.AnError) + return mock + }, + wantCode: http.StatusInternalServerError, + wantContains: []string{`"error"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + jobHandler := apijob.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(jobHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, "/job/status", nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacJobStatusTestSigningKey = "test-signing-key-for-rbac-integration" + +func (s *JobStatusPublicTestSuite) TestGetJobStatusRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobStatusTestSigningKey, + []string{"read"}, + "test-user", + []string{"network:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with job:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacJobStatusTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + GetQueueStats(gomock.Any()). + Return(&jobtypes.QueueStats{ + TotalJobs: 42, + StatusCounts: map[string]int{ + "completed": 30, + "failed": 5, + }, + OperationCounts: map[string]int{ + "node.hostname.get": 15, + }, + DLQCount: 2, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"total_jobs":42`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacJobStatusTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetJobHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/job/status", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestJobStatusPublicTestSuite(t *testing.T) { suite.Run(t, new(JobStatusPublicTestSuite)) } diff --git a/internal/api/metrics/metrics_get_integration_test.go b/internal/api/metrics/metrics_get_integration_test.go deleted file mode 100644 index 93bc60a89..000000000 --- a/internal/api/metrics/metrics_get_integration_test.go +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package metrics_test - -import ( - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/config" -) - -type MetricsGetIntegrationTestSuite struct { - suite.Suite - - appConfig config.Config - logger *slog.Logger -} - -func (suite *MetricsGetIntegrationTestSuite) SetupTest() { - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *MetricsGetIntegrationTestSuite) TestGetMetrics() { - tests := []struct { - name string - path string - wantCode int - wantContains string - }{ - { - name: "when metrics endpoint is wired returns prometheus text", - path: "/metrics", - wantCode: http.StatusOK, - wantContains: "test_metric 42", - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - metricsHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - _, _ = w.Write( - []byte( - "# HELP test_metric A test metric.\n# TYPE test_metric gauge\ntest_metric 42\n", - ), - ) - }) - - a := api.New(suite.appConfig, suite.logger) - handlers := a.GetMetricsHandler(metricsHandler, tc.path) - a.RegisterHandlers(handlers) - - req := httptest.NewRequest(http.MethodGet, tc.path, nil) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - suite.Contains(rec.Body.String(), tc.wantContains) - }) - } -} - -func TestMetricsGetIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(MetricsGetIntegrationTestSuite)) -} diff --git a/internal/api/metrics/metrics_get_public_test.go b/internal/api/metrics/metrics_get_public_test.go index 50346f0f3..88c9e90c0 100644 --- a/internal/api/metrics/metrics_get_public_test.go +++ b/internal/api/metrics/metrics_get_public_test.go @@ -21,17 +21,30 @@ package metrics_test import ( + "log/slog" "net/http" + "net/http/httptest" + "os" "testing" "github.com/labstack/echo/v4" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" "github.com/retr0h/osapi/internal/api/metrics" + "github.com/retr0h/osapi/internal/config" ) type MetricsGetPublicTestSuite struct { suite.Suite + + appConfig config.Config + logger *slog.Logger +} + +func (s *MetricsGetPublicTestSuite) SetupTest() { + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *MetricsGetPublicTestSuite) TestRegisterHandler() { @@ -76,6 +89,51 @@ func (s *MetricsGetPublicTestSuite) TestRegisterHandler() { } } +func (s *MetricsGetPublicTestSuite) TestGetMetricsHTTP() { + tests := []struct { + name string + path string + wantCode int + wantContains string + }{ + { + name: "when metrics endpoint is wired returns prometheus text", + path: "/metrics", + wantCode: http.StatusOK, + wantContains: "test_metric 42", + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + metricsHandler := http.HandlerFunc(func( + w http.ResponseWriter, + _ *http.Request, + ) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + "# HELP test_metric A test metric.\n# TYPE test_metric gauge\ntest_metric 42\n", + ), + ) + }) + + a := api.New(s.appConfig, s.logger) + handlers := a.GetMetricsHandler(metricsHandler, tc.path) + a.RegisterHandlers(handlers) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + s.Contains(rec.Body.String(), tc.wantContains) + }) + } +} + func TestMetricsGetPublicTestSuite(t *testing.T) { suite.Run(t, new(MetricsGetPublicTestSuite)) } diff --git a/internal/api/node/command_exec_post_integration_test.go b/internal/api/node/command_exec_post_integration_test.go deleted file mode 100644 index dfb39ba8b..000000000 --- a/internal/api/node/command_exec_post_integration_test.go +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package node_test - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/node" - nodeGen "github.com/retr0h/osapi/internal/api/node/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" - "github.com/retr0h/osapi/internal/provider/command" - "github.com/retr0h/osapi/internal/validation" -) - -type CommandExecPostIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *CommandExecPostIntegrationTestSuite) SetupSuite() { - validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { - return []validation.AgentTarget{ - {Hostname: "server1", Labels: map[string]string{"group": "web"}}, - {Hostname: "server2"}, - }, nil - }) -} - -func (suite *CommandExecPostIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *CommandExecPostIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *CommandExecPostIntegrationTestSuite) TestPostCommandExecValidation() { - tests := []struct { - name string - path string - body string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid request", - path: "/node/server1/command/exec", - body: `{"command":"ls","args":["-la"]}`, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ModifyCommandExec(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return("550e8400-e29b-41d4-a716-446655440000", &command.Result{ - Stdout: "file1\nfile2", - Stderr: "", - ExitCode: 0, - DurationMs: 42, - Changed: true, - }, "agent1", nil) - return mock - }, - wantCode: http.StatusAccepted, - wantContains: []string{`"results"`, `"agent1"`, `"changed":true`}, - }, - { - name: "when missing command", - path: "/node/server1/command/exec", - body: `{}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "Command", "required"}, - }, - { - name: "when invalid timeout", - path: "/node/server1/command/exec", - body: `{"command":"ls","timeout":999}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "Timeout", "max"}, - }, - { - name: "when target agent not found", - path: "/node/nonexistent/command/exec", - body: `{"command":"ls"}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "valid_target", "not found"}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - nodeHandler := node.New(suite.logger, jobMock) - strictHandler := nodeGen.NewStrictHandler(nodeHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - nodeGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodPost, - tc.path, - strings.NewReader(tc.body), - ) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacExecTestSigningKey = "test-signing-key-for-exec-rbac" - -func (suite *CommandExecPostIntegrationTestSuite) TestPostCommandExecRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacExecTestSigningKey, - []string{"read"}, - "test-user", - []string{"network:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with command:execute returns 202", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacExecTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ModifyCommandExec(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return( - "550e8400-e29b-41d4-a716-446655440000", - &command.Result{ - Stdout: "output", - Stderr: "", - ExitCode: 0, - DurationMs: 10, - Changed: true, - }, - "agent1", - nil, - ) - return mock - }, - wantCode: http.StatusAccepted, - wantContains: []string{`"results"`, `"changed":true`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacExecTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetNodeHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodPost, - "/node/server1/command/exec", - strings.NewReader(`{"command":"ls","args":["-la"]}`), - ) - req.Header.Set("Content-Type", "application/json") - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestCommandExecPostIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(CommandExecPostIntegrationTestSuite)) -} diff --git a/internal/api/node/command_exec_post_public_test.go b/internal/api/node/command_exec_post_public_test.go index f4c9c0df6..10fc0901b 100644 --- a/internal/api/node/command_exec_post_public_test.go +++ b/internal/api/node/command_exec_post_public_test.go @@ -22,15 +22,23 @@ package node_test import ( "context" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apinode "github.com/retr0h/osapi/internal/api/node" "github.com/retr0h/osapi/internal/api/node/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobmocks "github.com/retr0h/osapi/internal/job/mocks" "github.com/retr0h/osapi/internal/provider/command" "github.com/retr0h/osapi/internal/validation" @@ -43,6 +51,8 @@ type CommandExecPostPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apinode.Node ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *CommandExecPostPublicTestSuite) SetupSuite() { @@ -59,6 +69,8 @@ func (s *CommandExecPostPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apinode.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *CommandExecPostPublicTestSuite) TearDownTest() { @@ -356,6 +368,208 @@ func (s *CommandExecPostPublicTestSuite) TestPostNodeCommandExec() { } } +func (s *CommandExecPostPublicTestSuite) TestPostCommandExecHTTP() { + tests := []struct { + name string + path string + body string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/command/exec", + body: `{"command":"ls","args":["-la"]}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ModifyCommandExec(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return("550e8400-e29b-41d4-a716-446655440000", &command.Result{ + Stdout: "file1\nfile2", + Stderr: "", + ExitCode: 0, + DurationMs: 42, + Changed: true, + }, "agent1", nil) + return mock + }, + wantCode: http.StatusAccepted, + wantContains: []string{`"results"`, `"agent1"`, `"changed":true`}, + }, + { + name: "when missing command", + path: "/node/server1/command/exec", + body: `{}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "Command", "required"}, + }, + { + name: "when invalid timeout", + path: "/node/server1/command/exec", + body: `{"command":"ls","timeout":999}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "Timeout", "max"}, + }, + { + name: "when target agent not found", + path: "/node/nonexistent/command/exec", + body: `{"command":"ls"}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "valid_target", "not found"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + nodeHandler := apinode.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(nodeHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodPost, + tc.path, + strings.NewReader(tc.body), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacExecTestSigningKey = "test-signing-key-for-exec-rbac" + +func (s *CommandExecPostPublicTestSuite) TestPostCommandExecRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacExecTestSigningKey, + []string{"read"}, + "test-user", + []string{"network:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with command:execute returns 202", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacExecTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ModifyCommandExec(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return( + "550e8400-e29b-41d4-a716-446655440000", + &command.Result{ + Stdout: "output", + Stderr: "", + ExitCode: 0, + DurationMs: 10, + Changed: true, + }, + "agent1", + nil, + ) + return mock + }, + wantCode: http.StatusAccepted, + wantContains: []string{`"results"`, `"changed":true`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacExecTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetNodeHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodPost, + "/node/server1/command/exec", + strings.NewReader(`{"command":"ls","args":["-la"]}`), + ) + req.Header.Set("Content-Type", "application/json") + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestCommandExecPostPublicTestSuite(t *testing.T) { suite.Run(t, new(CommandExecPostPublicTestSuite)) } diff --git a/internal/api/node/command_shell_post_integration_test.go b/internal/api/node/command_shell_post_integration_test.go deleted file mode 100644 index 72b5e6f9b..000000000 --- a/internal/api/node/command_shell_post_integration_test.go +++ /dev/null @@ -1,278 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package node_test - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/node" - nodeGen "github.com/retr0h/osapi/internal/api/node/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" - "github.com/retr0h/osapi/internal/provider/command" - "github.com/retr0h/osapi/internal/validation" -) - -type CommandShellPostIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *CommandShellPostIntegrationTestSuite) SetupSuite() { - validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { - return []validation.AgentTarget{ - {Hostname: "server1", Labels: map[string]string{"group": "web"}}, - {Hostname: "server2"}, - }, nil - }) -} - -func (suite *CommandShellPostIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *CommandShellPostIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *CommandShellPostIntegrationTestSuite) TestPostCommandShellValidation() { - tests := []struct { - name string - path string - body string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid request", - path: "/node/server1/command/shell", - body: `{"command":"echo hello"}`, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ModifyCommandShell(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any()). - Return("550e8400-e29b-41d4-a716-446655440000", &command.Result{ - Stdout: "hello", - Stderr: "", - ExitCode: 0, - DurationMs: 15, - Changed: true, - }, "agent1", nil) - return mock - }, - wantCode: http.StatusAccepted, - wantContains: []string{`"results"`, `"agent1"`, `"changed":true`}, - }, - { - name: "when missing command", - path: "/node/server1/command/shell", - body: `{}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "Command", "required"}, - }, - { - name: "when invalid timeout", - path: "/node/server1/command/shell", - body: `{"command":"echo hello","timeout":999}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "Timeout", "max"}, - }, - { - name: "when target agent not found", - path: "/node/nonexistent/command/shell", - body: `{"command":"echo hello"}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "valid_target", "not found"}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - nodeHandler := node.New(suite.logger, jobMock) - strictHandler := nodeGen.NewStrictHandler(nodeHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - nodeGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodPost, - tc.path, - strings.NewReader(tc.body), - ) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacShellTestSigningKey = "test-signing-key-for-shell-rbac" - -func (suite *CommandShellPostIntegrationTestSuite) TestPostCommandShellRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacShellTestSigningKey, - []string{"read"}, - "test-user", - []string{"network:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with command:execute returns 202", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacShellTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ModifyCommandShell(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any()). - Return( - "550e8400-e29b-41d4-a716-446655440000", - &command.Result{ - Stdout: "hello", - Stderr: "", - ExitCode: 0, - DurationMs: 10, - Changed: true, - }, - "agent1", - nil, - ) - return mock - }, - wantCode: http.StatusAccepted, - wantContains: []string{`"results"`, `"changed":true`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacShellTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetNodeHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodPost, - "/node/server1/command/shell", - strings.NewReader(`{"command":"echo hello"}`), - ) - req.Header.Set("Content-Type", "application/json") - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestCommandShellPostIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(CommandShellPostIntegrationTestSuite)) -} diff --git a/internal/api/node/command_shell_post_public_test.go b/internal/api/node/command_shell_post_public_test.go index 9070f9379..6b4eb8189 100644 --- a/internal/api/node/command_shell_post_public_test.go +++ b/internal/api/node/command_shell_post_public_test.go @@ -22,15 +22,23 @@ package node_test import ( "context" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apinode "github.com/retr0h/osapi/internal/api/node" "github.com/retr0h/osapi/internal/api/node/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobmocks "github.com/retr0h/osapi/internal/job/mocks" "github.com/retr0h/osapi/internal/provider/command" "github.com/retr0h/osapi/internal/validation" @@ -43,6 +51,8 @@ type CommandShellPostPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apinode.Node ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *CommandShellPostPublicTestSuite) SetupSuite() { @@ -59,6 +69,8 @@ func (s *CommandShellPostPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apinode.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *CommandShellPostPublicTestSuite) TearDownTest() { @@ -332,6 +344,208 @@ func (s *CommandShellPostPublicTestSuite) TestPostNodeCommandShell() { } } +func (s *CommandShellPostPublicTestSuite) TestPostCommandShellHTTP() { + tests := []struct { + name string + path string + body string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/command/shell", + body: `{"command":"echo hello"}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ModifyCommandShell(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any()). + Return("550e8400-e29b-41d4-a716-446655440000", &command.Result{ + Stdout: "hello", + Stderr: "", + ExitCode: 0, + DurationMs: 15, + Changed: true, + }, "agent1", nil) + return mock + }, + wantCode: http.StatusAccepted, + wantContains: []string{`"results"`, `"agent1"`, `"changed":true`}, + }, + { + name: "when missing command", + path: "/node/server1/command/shell", + body: `{}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "Command", "required"}, + }, + { + name: "when invalid timeout", + path: "/node/server1/command/shell", + body: `{"command":"echo hello","timeout":999}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "Timeout", "max"}, + }, + { + name: "when target agent not found", + path: "/node/nonexistent/command/shell", + body: `{"command":"echo hello"}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "valid_target", "not found"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + nodeHandler := apinode.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(nodeHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodPost, + tc.path, + strings.NewReader(tc.body), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacShellTestSigningKey = "test-signing-key-for-shell-rbac" + +func (s *CommandShellPostPublicTestSuite) TestPostCommandShellRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacShellTestSigningKey, + []string{"read"}, + "test-user", + []string{"network:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with command:execute returns 202", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacShellTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ModifyCommandShell(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any()). + Return( + "550e8400-e29b-41d4-a716-446655440000", + &command.Result{ + Stdout: "hello", + Stderr: "", + ExitCode: 0, + DurationMs: 10, + Changed: true, + }, + "agent1", + nil, + ) + return mock + }, + wantCode: http.StatusAccepted, + wantContains: []string{`"results"`, `"changed":true`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacShellTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetNodeHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodPost, + "/node/server1/command/shell", + strings.NewReader(`{"command":"echo hello"}`), + ) + req.Header.Set("Content-Type", "application/json") + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestCommandShellPostPublicTestSuite(t *testing.T) { suite.Run(t, new(CommandShellPostPublicTestSuite)) } diff --git a/internal/api/node/network_dns_get_by_interface_integration_test.go b/internal/api/node/network_dns_get_by_interface_integration_test.go deleted file mode 100644 index 0f594417d..000000000 --- a/internal/api/node/network_dns_get_by_interface_integration_test.go +++ /dev/null @@ -1,262 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package node_test - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/node" - nodeGen "github.com/retr0h/osapi/internal/api/node/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" - "github.com/retr0h/osapi/internal/provider/network/dns" - "github.com/retr0h/osapi/internal/validation" -) - -type NetworkDNSGetByInterfaceIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) SetupSuite() { - validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { - return []validation.AgentTarget{ - {Hostname: "server1", Labels: map[string]string{"group": "web"}}, - {Hostname: "server2"}, - }, nil - }) -} - -func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) TestGetNetworkDNSByInterfaceValidation() { - tests := []struct { - name string - path string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid request", - path: "/node/server1/network/dns/eth0", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNetworkDNS(gomock.Any(), "server1", "eth0"). - Return("550e8400-e29b-41d4-a716-446655440000", &dns.Config{ - DNSServers: []string{"8.8.8.8"}, - SearchDomains: []string{"example.com"}, - }, "agent1", nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{ - `"results"`, - `"servers"`, - `"8.8.8.8"`, - `"search_domains"`, - `"example.com"`, - }, - }, - { - name: "when non-alphanum interface name", - path: "/node/server1/network/dns/eth-0!", - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "alphanum"}, - }, - { - name: "when broadcast all", - path: "/node/_all/network/dns/eth0", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNetworkDNSBroadcast(gomock.Any(), "_all", "eth0"). - Return("550e8400-e29b-41d4-a716-446655440000", map[string]*dns.Config{ - "server1": { - DNSServers: []string{"8.8.8.8"}, - SearchDomains: []string{"example.com"}, - }, - }, map[string]string{}, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"results"`, `"8.8.8.8"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - nodeHandler := node.New(suite.logger, jobMock) - strictHandler := nodeGen.NewStrictHandler(nodeHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - nodeGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest(http.MethodGet, tc.path, nil) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacDNSGetTestSigningKey = "test-signing-key-for-dns-get-rbac" - -func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) TestGetNetworkDNSByInterfaceRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacDNSGetTestSigningKey, - []string{"read"}, - "test-user", - []string{"job:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with network:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacDNSGetTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNetworkDNS(gomock.Any(), "server1", "eth0"). - Return( - "550e8400-e29b-41d4-a716-446655440000", - &dns.Config{ - DNSServers: []string{"8.8.8.8"}, - SearchDomains: []string{"example.com"}, - }, - "agent1", - nil, - ) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"results"`, `"8.8.8.8"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacDNSGetTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetNodeHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest(http.MethodGet, "/node/server1/network/dns/eth0", nil) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestNetworkDNSGetByInterfaceIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(NetworkDNSGetByInterfaceIntegrationTestSuite)) -} diff --git a/internal/api/node/network_dns_get_by_interface_public_test.go b/internal/api/node/network_dns_get_by_interface_public_test.go index 17f0fd0e3..1f4012673 100644 --- a/internal/api/node/network_dns_get_by_interface_public_test.go +++ b/internal/api/node/network_dns_get_by_interface_public_test.go @@ -22,15 +22,22 @@ package node_test import ( "context" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apinode "github.com/retr0h/osapi/internal/api/node" "github.com/retr0h/osapi/internal/api/node/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobmocks "github.com/retr0h/osapi/internal/job/mocks" "github.com/retr0h/osapi/internal/provider/network/dns" "github.com/retr0h/osapi/internal/validation" @@ -43,6 +50,8 @@ type NetworkDNSGetByInterfacePublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apinode.Node ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *NetworkDNSGetByInterfacePublicTestSuite) SetupSuite() { @@ -59,6 +68,8 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apinode.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *NetworkDNSGetByInterfacePublicTestSuite) TearDownTest() { @@ -242,6 +253,193 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNodeNetworkDNSByInterfa } } +func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterfaceHTTP() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/network/dns/eth0", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNetworkDNS(gomock.Any(), "server1", "eth0"). + Return("550e8400-e29b-41d4-a716-446655440000", &dns.Config{ + DNSServers: []string{"8.8.8.8"}, + SearchDomains: []string{"example.com"}, + }, "agent1", nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{ + `"results"`, + `"servers"`, + `"8.8.8.8"`, + `"search_domains"`, + `"example.com"`, + }, + }, + { + name: "when non-alphanum interface name", + path: "/node/server1/network/dns/eth-0!", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "alphanum"}, + }, + { + name: "when broadcast all", + path: "/node/_all/network/dns/eth0", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNetworkDNSBroadcast(gomock.Any(), "_all", "eth0"). + Return("550e8400-e29b-41d4-a716-446655440000", map[string]*dns.Config{ + "server1": { + DNSServers: []string{"8.8.8.8"}, + SearchDomains: []string{"example.com"}, + }, + }, map[string]string{}, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"8.8.8.8"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + nodeHandler := apinode.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(nodeHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacDNSGetTestSigningKey = "test-signing-key-for-dns-get-rbac" + +func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterfaceRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacDNSGetTestSigningKey, + []string{"read"}, + "test-user", + []string{"job:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with network:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacDNSGetTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNetworkDNS(gomock.Any(), "server1", "eth0"). + Return( + "550e8400-e29b-41d4-a716-446655440000", + &dns.Config{ + DNSServers: []string{"8.8.8.8"}, + SearchDomains: []string{"example.com"}, + }, + "agent1", + nil, + ) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"8.8.8.8"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacDNSGetTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetNodeHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest(http.MethodGet, "/node/server1/network/dns/eth0", nil) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestNetworkDNSGetByInterfacePublicTestSuite(t *testing.T) { suite.Run(t, new(NetworkDNSGetByInterfacePublicTestSuite)) } diff --git a/internal/api/node/network_dns_put_by_interface_integration_test.go b/internal/api/node/network_dns_put_by_interface_integration_test.go deleted file mode 100644 index 9b89ec51d..000000000 --- a/internal/api/node/network_dns_put_by_interface_integration_test.go +++ /dev/null @@ -1,303 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package node_test - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/node" - nodeGen "github.com/retr0h/osapi/internal/api/node/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" - "github.com/retr0h/osapi/internal/validation" -) - -type NetworkDNSPutByInterfaceIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) SetupSuite() { - validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { - return []validation.AgentTarget{ - {Hostname: "server1", Labels: map[string]string{"group": "web"}}, - {Hostname: "server2"}, - }, nil - }) -} - -func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNSValidation() { - tests := []struct { - name string - path string - body string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid request", - path: "/node/server1/network/dns", - body: `{"servers":["1.1.1.1","8.8.8.8"],"search_domains":["foo.bar"],"interface_name":"eth0"}`, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ModifyNetworkDNS(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any()). - Return("550e8400-e29b-41d4-a716-446655440000", "agent1", true, nil) - return mock - }, - wantCode: http.StatusAccepted, - wantContains: []string{`"results"`, `"agent1"`, `"ok"`, `"changed":true`}, - }, - { - name: "when missing interface name", - path: "/node/server1/network/dns", - body: `{"servers":["1.1.1.1"]}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "InterfaceName", "required"}, - }, - { - name: "when non-alphanum interface name", - path: "/node/server1/network/dns", - body: `{"servers":["1.1.1.1"],"interface_name":"eth-0!"}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "InterfaceName", "alphanum"}, - }, - { - name: "when invalid server IP", - path: "/node/server1/network/dns", - body: `{"servers":["not-an-ip"],"interface_name":"eth0"}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "Servers", "ip"}, - }, - { - name: "when invalid search domain", - path: "/node/server1/network/dns", - body: `{"search_domains":["not a valid hostname!"],"interface_name":"eth0"}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "SearchDomains", "hostname"}, - }, - { - name: "when target agent not found", - path: "/node/nonexistent/network/dns", - body: `{"servers":["1.1.1.1"],"interface_name":"eth0"}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "valid_target", "not found"}, - }, - { - name: "when broadcast all", - path: "/node/_all/network/dns", - body: `{"servers":["1.1.1.1"],"search_domains":["foo.bar"],"interface_name":"eth0"}`, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ModifyNetworkDNSBroadcast(gomock.Any(), "_all", gomock.Any(), gomock.Any(), gomock.Any()). - Return("550e8400-e29b-41d4-a716-446655440000", map[string]error{ - "server1": nil, - }, map[string]bool{ - "server1": true, - }, nil) - return mock - }, - wantCode: http.StatusAccepted, - wantContains: []string{`"results"`, `"server1"`, `"changed":true`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - nodeHandler := node.New(suite.logger, jobMock) - strictHandler := nodeGen.NewStrictHandler(nodeHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - nodeGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodPut, - tc.path, - strings.NewReader(tc.body), - ) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacDNSPutTestSigningKey = "test-signing-key-for-dns-put-rbac" - -func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNSRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacDNSPutTestSigningKey, - []string{"read"}, - "test-user", - []string{"network:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with network:write returns 202", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacDNSPutTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - ModifyNetworkDNS(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any()). - Return( - "550e8400-e29b-41d4-a716-446655440000", - "agent1", - true, - nil, - ) - return mock - }, - wantCode: http.StatusAccepted, - wantContains: []string{`"results"`, `"changed":true`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacDNSPutTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetNodeHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodPut, - "/node/server1/network/dns", - strings.NewReader(`{"servers":["8.8.8.8"],"interface_name":"eth0"}`), - ) - req.Header.Set("Content-Type", "application/json") - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestNetworkDNSPutByInterfaceIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(NetworkDNSPutByInterfaceIntegrationTestSuite)) -} diff --git a/internal/api/node/network_dns_put_by_interface_public_test.go b/internal/api/node/network_dns_put_by_interface_public_test.go index 961836a20..7ff6aa0af 100644 --- a/internal/api/node/network_dns_put_by_interface_public_test.go +++ b/internal/api/node/network_dns_put_by_interface_public_test.go @@ -23,15 +23,23 @@ package node_test import ( "context" "errors" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apinode "github.com/retr0h/osapi/internal/api/node" "github.com/retr0h/osapi/internal/api/node/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobmocks "github.com/retr0h/osapi/internal/job/mocks" "github.com/retr0h/osapi/internal/validation" ) @@ -43,6 +51,8 @@ type NetworkDNSPutByInterfacePublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apinode.Node ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *NetworkDNSPutByInterfacePublicTestSuite) SetupSuite() { @@ -59,6 +69,8 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apinode.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *NetworkDNSPutByInterfacePublicTestSuite) TearDownTest() { @@ -287,6 +299,234 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNodeNetworkDNS() { } } +func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNSHTTP() { + tests := []struct { + name string + path string + body string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/network/dns", + body: `{"servers":["1.1.1.1","8.8.8.8"],"search_domains":["foo.bar"],"interface_name":"eth0"}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ModifyNetworkDNS(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any()). + Return("550e8400-e29b-41d4-a716-446655440000", "agent1", true, nil) + return mock + }, + wantCode: http.StatusAccepted, + wantContains: []string{`"results"`, `"agent1"`, `"ok"`, `"changed":true`}, + }, + { + name: "when missing interface name", + path: "/node/server1/network/dns", + body: `{"servers":["1.1.1.1"]}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "InterfaceName", "required"}, + }, + { + name: "when non-alphanum interface name", + path: "/node/server1/network/dns", + body: `{"servers":["1.1.1.1"],"interface_name":"eth-0!"}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "InterfaceName", "alphanum"}, + }, + { + name: "when invalid server IP", + path: "/node/server1/network/dns", + body: `{"servers":["not-an-ip"],"interface_name":"eth0"}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "Servers", "ip"}, + }, + { + name: "when invalid search domain", + path: "/node/server1/network/dns", + body: `{"search_domains":["not a valid hostname!"],"interface_name":"eth0"}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "SearchDomains", "hostname"}, + }, + { + name: "when target agent not found", + path: "/node/nonexistent/network/dns", + body: `{"servers":["1.1.1.1"],"interface_name":"eth0"}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "valid_target", "not found"}, + }, + { + name: "when broadcast all", + path: "/node/_all/network/dns", + body: `{"servers":["1.1.1.1"],"search_domains":["foo.bar"],"interface_name":"eth0"}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ModifyNetworkDNSBroadcast(gomock.Any(), "_all", gomock.Any(), gomock.Any(), gomock.Any()). + Return("550e8400-e29b-41d4-a716-446655440000", map[string]error{ + "server1": nil, + }, map[string]bool{ + "server1": true, + }, nil) + return mock + }, + wantCode: http.StatusAccepted, + wantContains: []string{`"results"`, `"server1"`, `"changed":true`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + nodeHandler := apinode.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(nodeHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodPut, + tc.path, + strings.NewReader(tc.body), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacDNSPutTestSigningKey = "test-signing-key-for-dns-put-rbac" + +func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNSRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacDNSPutTestSigningKey, + []string{"read"}, + "test-user", + []string{"network:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with network:write returns 202", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacDNSPutTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + ModifyNetworkDNS(gomock.Any(), "server1", gomock.Any(), gomock.Any(), gomock.Any()). + Return( + "550e8400-e29b-41d4-a716-446655440000", + "agent1", + true, + nil, + ) + return mock + }, + wantCode: http.StatusAccepted, + wantContains: []string{`"results"`, `"changed":true`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacDNSPutTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetNodeHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodPut, + "/node/server1/network/dns", + strings.NewReader(`{"servers":["8.8.8.8"],"interface_name":"eth0"}`), + ) + req.Header.Set("Content-Type", "application/json") + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestNetworkDNSPutByInterfacePublicTestSuite(t *testing.T) { suite.Run(t, new(NetworkDNSPutByInterfacePublicTestSuite)) } diff --git a/internal/api/node/network_ping_post_integration_test.go b/internal/api/node/network_ping_post_integration_test.go deleted file mode 100644 index dbc6067e3..000000000 --- a/internal/api/node/network_ping_post_integration_test.go +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright (c) 2026 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package node_test - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "strings" - "testing" - "time" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/node" - nodeGen "github.com/retr0h/osapi/internal/api/node/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" - "github.com/retr0h/osapi/internal/provider/network/ping" - "github.com/retr0h/osapi/internal/validation" -) - -type NetworkPingPostIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *NetworkPingPostIntegrationTestSuite) SetupSuite() { - validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { - return []validation.AgentTarget{ - {Hostname: "server1", Labels: map[string]string{"group": "web"}}, - {Hostname: "server2"}, - }, nil - }) -} - -func (suite *NetworkPingPostIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *NetworkPingPostIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPingValidation() { - tests := []struct { - name string - path string - body string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when valid request", - path: "/node/server1/network/ping", - body: `{"address":"1.1.1.1"}`, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNetworkPing(gomock.Any(), "server1", "1.1.1.1"). - Return("550e8400-e29b-41d4-a716-446655440000", &ping.Result{ - PacketsSent: 3, - PacketsReceived: 3, - PacketLoss: 0, - MinRTT: 10 * time.Millisecond, - AvgRTT: 15 * time.Millisecond, - MaxRTT: 20 * time.Millisecond, - }, "agent1", nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"results"`, `"packets_sent":3`, `"packets_received":3`}, - }, - { - name: "when missing address", - path: "/node/server1/network/ping", - body: `{}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "Address", "required"}, - }, - { - name: "when invalid address format", - path: "/node/server1/network/ping", - body: `{"address":"not-an-ip"}`, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusBadRequest, - wantContains: []string{`"error"`, "Address", "ip"}, - }, - { - name: "when broadcast all", - path: "/node/_all/network/ping", - body: `{"address":"1.1.1.1"}`, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNetworkPingBroadcast(gomock.Any(), "_all", "1.1.1.1"). - Return("550e8400-e29b-41d4-a716-446655440000", map[string]*ping.Result{ - "server1": { - PacketsSent: 3, - PacketsReceived: 3, - PacketLoss: 0, - MinRTT: 10 * time.Millisecond, - AvgRTT: 15 * time.Millisecond, - MaxRTT: 20 * time.Millisecond, - }, - }, map[string]string{}, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"results"`, `"packets_sent":3`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - nodeHandler := node.New(suite.logger, jobMock) - strictHandler := nodeGen.NewStrictHandler(nodeHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - nodeGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest( - http.MethodPost, - tc.path, - strings.NewReader(tc.body), - ) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacPingTestSigningKey = "test-signing-key-for-ping-rbac" - -func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPingRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacPingTestSigningKey, - []string{"read"}, - "test-user", - []string{"network:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with network:write returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacPingTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNetworkPing(gomock.Any(), "server1", "8.8.8.8"). - Return( - "550e8400-e29b-41d4-a716-446655440000", - &ping.Result{ - PacketsSent: 3, - PacketsReceived: 3, - PacketLoss: 0, - MinRTT: 10 * time.Millisecond, - AvgRTT: 15 * time.Millisecond, - MaxRTT: 20 * time.Millisecond, - }, - "agent1", - nil, - ) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"results"`, `"packets_sent":3`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacPingTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetNodeHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest( - http.MethodPost, - "/node/server1/network/ping", - strings.NewReader(`{"address":"8.8.8.8"}`), - ) - req.Header.Set("Content-Type", "application/json") - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -func TestNetworkPingPostIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(NetworkPingPostIntegrationTestSuite)) -} diff --git a/internal/api/node/network_ping_post_public_test.go b/internal/api/node/network_ping_post_public_test.go index 79ae711d5..5f0773533 100644 --- a/internal/api/node/network_ping_post_public_test.go +++ b/internal/api/node/network_ping_post_public_test.go @@ -22,7 +22,12 @@ package node_test import ( "context" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" "testing" "time" @@ -30,8 +35,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apinode "github.com/retr0h/osapi/internal/api/node" "github.com/retr0h/osapi/internal/api/node/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobmocks "github.com/retr0h/osapi/internal/job/mocks" "github.com/retr0h/osapi/internal/provider/network/ping" "github.com/retr0h/osapi/internal/validation" @@ -44,6 +52,8 @@ type NetworkPingPostPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apinode.Node ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *NetworkPingPostPublicTestSuite) SetupSuite() { @@ -60,6 +70,8 @@ func (s *NetworkPingPostPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apinode.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *NetworkPingPostPublicTestSuite) TearDownTest() { @@ -263,6 +275,223 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNodeNetworkPing() { } } +func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPingHTTP() { + tests := []struct { + name string + path string + body string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/network/ping", + body: `{"address":"1.1.1.1"}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNetworkPing(gomock.Any(), "server1", "1.1.1.1"). + Return("550e8400-e29b-41d4-a716-446655440000", &ping.Result{ + PacketsSent: 3, + PacketsReceived: 3, + PacketLoss: 0, + MinRTT: 10 * time.Millisecond, + AvgRTT: 15 * time.Millisecond, + MaxRTT: 20 * time.Millisecond, + }, "agent1", nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"packets_sent":3`, `"packets_received":3`}, + }, + { + name: "when missing address", + path: "/node/server1/network/ping", + body: `{}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "Address", "required"}, + }, + { + name: "when invalid address format", + path: "/node/server1/network/ping", + body: `{"address":"not-an-ip"}`, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "Address", "ip"}, + }, + { + name: "when broadcast all", + path: "/node/_all/network/ping", + body: `{"address":"1.1.1.1"}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNetworkPingBroadcast(gomock.Any(), "_all", "1.1.1.1"). + Return("550e8400-e29b-41d4-a716-446655440000", map[string]*ping.Result{ + "server1": { + PacketsSent: 3, + PacketsReceived: 3, + PacketLoss: 0, + MinRTT: 10 * time.Millisecond, + AvgRTT: 15 * time.Millisecond, + MaxRTT: 20 * time.Millisecond, + }, + }, map[string]string{}, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"packets_sent":3`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + nodeHandler := apinode.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(nodeHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest( + http.MethodPost, + tc.path, + strings.NewReader(tc.body), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacPingTestSigningKey = "test-signing-key-for-ping-rbac" + +func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPingRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacPingTestSigningKey, + []string{"read"}, + "test-user", + []string{"network:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with network:write returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacPingTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNetworkPing(gomock.Any(), "server1", "8.8.8.8"). + Return( + "550e8400-e29b-41d4-a716-446655440000", + &ping.Result{ + PacketsSent: 3, + PacketsReceived: 3, + PacketLoss: 0, + MinRTT: 10 * time.Millisecond, + AvgRTT: 15 * time.Millisecond, + MaxRTT: 20 * time.Millisecond, + }, + "agent1", + nil, + ) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"packets_sent":3`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacPingTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetNodeHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodPost, + "/node/server1/network/ping", + strings.NewReader(`{"address":"8.8.8.8"}`), + ) + req.Header.Set("Content-Type", "application/json") + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestNetworkPingPostPublicTestSuite(t *testing.T) { suite.Run(t, new(NetworkPingPostPublicTestSuite)) } diff --git a/internal/api/node/node_hostname_get_integration_test.go b/internal/api/node/node_hostname_get_integration_test.go deleted file mode 100644 index 3ee834537..000000000 --- a/internal/api/node/node_hostname_get_integration_test.go +++ /dev/null @@ -1,261 +0,0 @@ -// Copyright (c) 2024 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package node_test - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/node" - nodeGen "github.com/retr0h/osapi/internal/api/node/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - "github.com/retr0h/osapi/internal/job" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" - "github.com/retr0h/osapi/internal/validation" -) - -type NodeHostnameGetIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *NodeHostnameGetIntegrationTestSuite) SetupSuite() { - validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { - return []validation.AgentTarget{ - {Hostname: "server1", Labels: map[string]string{"group": "web"}}, - {Hostname: "server2"}, - }, nil - }) -} - -func (suite *NodeHostnameGetIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *NodeHostnameGetIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *NodeHostnameGetIntegrationTestSuite) TestGetNodeHostnameValidation() { - tests := []struct { - name string - path string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantBody string - wantContains []string - }{ - { - name: "when get Ok", - path: "/node/server1/hostname", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNodeHostname(gomock.Any(), "server1"). - Return("550e8400-e29b-41d4-a716-446655440000", "default-hostname", &job.AgentInfo{ - Hostname: "agent1", - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantBody: `{"job_id":"550e8400-e29b-41d4-a716-446655440000","results":[{"hostname":"default-hostname"}]}`, - }, - { - name: "when job client errors", - path: "/node/server1/hostname", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNodeHostname(gomock.Any(), "server1"). - Return("", "", nil, assert.AnError) - return mock - }, - wantCode: http.StatusInternalServerError, - wantBody: `{"error":"assert.AnError general error for testing"}`, - }, - { - name: "when broadcast all", - path: "/node/_all/hostname", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNodeHostnameBroadcast(gomock.Any(), "_all"). - Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.AgentInfo{ - "server1": {Hostname: "host1"}, - "server2": {Hostname: "host2"}, - }, map[string]string{}, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"results"`, `"host1"`, `"host2"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - nodeHandler := node.New(suite.logger, jobMock) - strictHandler := nodeGen.NewStrictHandler(nodeHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - nodeGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest(http.MethodGet, tc.path, nil) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - if tc.wantBody != "" { - suite.JSONEq(tc.wantBody, rec.Body.String()) - } - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *NodeHostnameGetIntegrationTestSuite) TestGetNodeHostnameRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacTestSigningKey, - []string{"read"}, - "test-user", - []string{"job:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with node:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNodeHostname(gomock.Any(), "server1"). - Return( - "550e8400-e29b-41d4-a716-446655440000", - "test-host", - &job.AgentInfo{Hostname: "agent1"}, - nil, - ) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"hostname":"test-host"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetNodeHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest(http.MethodGet, "/node/server1/hostname", nil) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -// In order for `go test` to run this suite, we need to create -// a normal test function and pass our suite to suite.Run. -func TestNodeHostnameGetIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(NodeHostnameGetIntegrationTestSuite)) -} diff --git a/internal/api/node/node_hostname_get_public_test.go b/internal/api/node/node_hostname_get_public_test.go index d1f8722a4..47d3f3a0b 100644 --- a/internal/api/node/node_hostname_get_public_test.go +++ b/internal/api/node/node_hostname_get_public_test.go @@ -22,15 +22,22 @@ package node_test import ( "context" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apinode "github.com/retr0h/osapi/internal/api/node" "github.com/retr0h/osapi/internal/api/node/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" "github.com/retr0h/osapi/internal/job" jobmocks "github.com/retr0h/osapi/internal/job/mocks" "github.com/retr0h/osapi/internal/validation" @@ -43,6 +50,8 @@ type NodeHostnameGetPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apinode.Node ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *NodeHostnameGetPublicTestSuite) SetupSuite() { @@ -59,6 +68,8 @@ func (s *NodeHostnameGetPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apinode.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *NodeHostnameGetPublicTestSuite) TearDownTest() { @@ -201,6 +212,189 @@ func (s *NodeHostnameGetPublicTestSuite) TestGetNodeHostname() { } } +func (s *NodeHostnameGetPublicTestSuite) TestGetNodeHostnameHTTP() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantBody string + wantContains []string + }{ + { + name: "when get Ok", + path: "/node/server1/hostname", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNodeHostname(gomock.Any(), "server1"). + Return("550e8400-e29b-41d4-a716-446655440000", "default-hostname", &job.AgentInfo{ + Hostname: "agent1", + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantBody: `{"job_id":"550e8400-e29b-41d4-a716-446655440000","results":[{"hostname":"default-hostname"}]}`, + }, + { + name: "when job client errors", + path: "/node/server1/hostname", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNodeHostname(gomock.Any(), "server1"). + Return("", "", nil, assert.AnError) + return mock + }, + wantCode: http.StatusInternalServerError, + wantBody: `{"error":"assert.AnError general error for testing"}`, + }, + { + name: "when broadcast all", + path: "/node/_all/hostname", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNodeHostnameBroadcast(gomock.Any(), "_all"). + Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.AgentInfo{ + "server1": {Hostname: "host1"}, + "server2": {Hostname: "host2"}, + }, map[string]string{}, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"host1"`, `"host2"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + nodeHandler := apinode.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(nodeHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + if tc.wantBody != "" { + s.JSONEq(tc.wantBody, rec.Body.String()) + } + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacTestSigningKey = "test-signing-key-for-rbac-integration" + +func (s *NodeHostnameGetPublicTestSuite) TestGetNodeHostnameRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacTestSigningKey, + []string{"read"}, + "test-user", + []string{"job:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with node:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNodeHostname(gomock.Any(), "server1"). + Return( + "550e8400-e29b-41d4-a716-446655440000", + "test-host", + &job.AgentInfo{Hostname: "agent1"}, + nil, + ) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"hostname":"test-host"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetNodeHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest(http.MethodGet, "/node/server1/hostname", nil) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestNodeHostnameGetPublicTestSuite(t *testing.T) { suite.Run(t, new(NodeHostnameGetPublicTestSuite)) } diff --git a/internal/api/node/node_status_get_integration_test.go b/internal/api/node/node_status_get_integration_test.go deleted file mode 100644 index 40ab538dc..000000000 --- a/internal/api/node/node_status_get_integration_test.go +++ /dev/null @@ -1,351 +0,0 @@ -// Copyright (c) 2024 John Dewey - -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to -// deal in the Software without restriction, including without limitation the -// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: - -// The above copyright notice and this permission notice shall be included in -// all copies or substantial portions of the Software. - -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -// DEALINGS IN THE SOFTWARE. - -package node_test - -import ( - "context" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "os" - "testing" - "time" - - "github.com/golang/mock/gomock" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" - - "github.com/retr0h/osapi/internal/api" - "github.com/retr0h/osapi/internal/api/node" - nodeGen "github.com/retr0h/osapi/internal/api/node/gen" - "github.com/retr0h/osapi/internal/authtoken" - "github.com/retr0h/osapi/internal/config" - "github.com/retr0h/osapi/internal/job" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" - "github.com/retr0h/osapi/internal/provider/node/disk" - "github.com/retr0h/osapi/internal/provider/node/host" - "github.com/retr0h/osapi/internal/provider/node/load" - "github.com/retr0h/osapi/internal/provider/node/mem" - "github.com/retr0h/osapi/internal/validation" -) - -type NodeStatusGetIntegrationTestSuite struct { - suite.Suite - ctrl *gomock.Controller - - appConfig config.Config - logger *slog.Logger -} - -func (suite *NodeStatusGetIntegrationTestSuite) SetupSuite() { - validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { - return []validation.AgentTarget{ - {Hostname: "server1", Labels: map[string]string{"group": "web"}}, - {Hostname: "server2"}, - }, nil - }) -} - -func (suite *NodeStatusGetIntegrationTestSuite) SetupTest() { - suite.ctrl = gomock.NewController(suite.T()) - - suite.appConfig = config.Config{} - suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) -} - -func (suite *NodeStatusGetIntegrationTestSuite) TearDownTest() { - suite.ctrl.Finish() -} - -func (suite *NodeStatusGetIntegrationTestSuite) TestGetNodeStatusValidation() { - tests := []struct { - name string - path string - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantBody string - wantContains []string - }{ - { - name: "when get Ok", - path: "/node/server1", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNodeStatus(gomock.Any(), "server1"). - Return("550e8400-e29b-41d4-a716-446655440000", &job.NodeStatusResponse{ - Hostname: "default-hostname", - Uptime: 5 * time.Hour, - OSInfo: &host.OSInfo{ - Distribution: "Ubuntu", - Version: "24.04", - }, - LoadAverages: &load.AverageStats{ - Load1: 1, - Load5: 0.5, - Load15: 0.2, - }, - MemoryStats: &mem.Stats{ - Total: 8388608, - Free: 4194304, - Cached: 2097152, - }, - DiskUsage: []disk.UsageStats{ - { - Name: "/dev/disk1", - Total: 500000000000, - Used: 250000000000, - Free: 250000000000, - }, - }, - }, nil) - return mock - }, - wantCode: http.StatusOK, - wantBody: ` -{ - "job_id": "550e8400-e29b-41d4-a716-446655440000", - "results": [ - { - "disks": [ - { - "free": 250000000000, - "name": "/dev/disk1", - "total": 500000000000, - "used": 250000000000 - } - ], - "hostname": "default-hostname", - "load_average": { - "1min": 1, - "5min": 0.5, - "15min": 0.2 - }, - "memory": { - "free": 4194304, - "total": 8388608, - "used": 2097152 - }, - "os_info": { - "distribution": "Ubuntu", - "version": "24.04" - }, - "uptime": "0 days, 5 hours, 0 minutes" - } - ] -} -`, - }, - { - name: "when job client errors", - path: "/node/server1", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNodeStatus(gomock.Any(), "server1"). - Return("", nil, assert.AnError) - return mock - }, - wantCode: http.StatusInternalServerError, - wantBody: `{"error":"assert.AnError general error for testing"}`, - }, - { - name: "when broadcast all", - path: "/node/_all", - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNodeStatusBroadcast(gomock.Any(), "_all"). - Return("550e8400-e29b-41d4-a716-446655440000", []*job.NodeStatusResponse{ - { - Hostname: "server1", - Uptime: time.Hour, - }, - { - Hostname: "server2", - Uptime: 2 * time.Hour, - }, - }, map[string]string{}, nil) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"results"`, `"server1"`, `"server2"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - nodeHandler := node.New(suite.logger, jobMock) - strictHandler := nodeGen.NewStrictHandler(nodeHandler, nil) - - a := api.New(suite.appConfig, suite.logger) - nodeGen.RegisterHandlers(a.Echo, strictHandler) - - req := httptest.NewRequest(http.MethodGet, tc.path, nil) - rec := httptest.NewRecorder() - - a.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - if tc.wantBody != "" { - suite.JSONEq(tc.wantBody, rec.Body.String()) - } - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -const rbacStatusTestSigningKey = "test-signing-key-for-rbac-integration" - -func (suite *NodeStatusGetIntegrationTestSuite) TestGetNodeStatusRBAC() { - tokenManager := authtoken.New(suite.logger) - - tests := []struct { - name string - setupAuth func(req *http.Request) - setupJobMock func() *jobmocks.MockJobClient - wantCode int - wantContains []string - }{ - { - name: "when no token returns 401", - setupAuth: func(_ *http.Request) { - // No auth header set - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusUnauthorized, - wantContains: []string{"Bearer token required"}, - }, - { - name: "when insufficient permissions returns 403", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacStatusTestSigningKey, - []string{"read"}, - "test-user", - []string{"job:read"}, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - return jobmocks.NewMockJobClient(suite.ctrl) - }, - wantCode: http.StatusForbidden, - wantContains: []string{"Insufficient permissions"}, - }, - { - name: "when valid token with node:read returns 200", - setupAuth: func(req *http.Request) { - token, err := tokenManager.Generate( - rbacStatusTestSigningKey, - []string{"admin"}, - "test-user", - nil, - ) - suite.Require().NoError(err) - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - }, - setupJobMock: func() *jobmocks.MockJobClient { - mock := jobmocks.NewMockJobClient(suite.ctrl) - mock.EXPECT(). - QueryNodeStatus(gomock.Any(), "server1"). - Return( - "550e8400-e29b-41d4-a716-446655440000", - &job.NodeStatusResponse{ - Hostname: "default-hostname", - Uptime: 5 * time.Hour, - OSInfo: &host.OSInfo{ - Distribution: "Ubuntu", - Version: "24.04", - }, - LoadAverages: &load.AverageStats{ - Load1: 1, - Load5: 0.5, - Load15: 0.2, - }, - MemoryStats: &mem.Stats{ - Total: 8388608, - Free: 4194304, - Cached: 2097152, - }, - DiskUsage: []disk.UsageStats{ - { - Name: "/dev/disk1", - Total: 500000000000, - Used: 250000000000, - Free: 250000000000, - }, - }, - }, - nil, - ) - return mock - }, - wantCode: http.StatusOK, - wantContains: []string{`"hostname":"default-hostname"`, `"job_id"`}, - }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - jobMock := tc.setupJobMock() - - appConfig := config.Config{ - API: config.API{ - Server: config.Server{ - Security: config.ServerSecurity{ - SigningKey: rbacStatusTestSigningKey, - }, - }, - }, - } - - server := api.New(appConfig, suite.logger) - handlers := server.GetNodeHandler(jobMock) - server.RegisterHandlers(handlers) - - req := httptest.NewRequest(http.MethodGet, "/node/server1", nil) - tc.setupAuth(req) - rec := httptest.NewRecorder() - - server.Echo.ServeHTTP(rec, req) - - suite.Equal(tc.wantCode, rec.Code) - for _, s := range tc.wantContains { - suite.Contains(rec.Body.String(), s) - } - }) - } -} - -// In order for `go test` to run this suite, we need to create -// a normal test function and pass our suite to suite.Run. -func TestNodeStatusGetIntegrationTestSuite(t *testing.T) { - suite.Run(t, new(NodeStatusGetIntegrationTestSuite)) -} diff --git a/internal/api/node/node_status_get_public_test.go b/internal/api/node/node_status_get_public_test.go index b34809eac..49cc2cef8 100644 --- a/internal/api/node/node_status_get_public_test.go +++ b/internal/api/node/node_status_get_public_test.go @@ -22,7 +22,11 @@ package node_test import ( "context" + "fmt" "log/slog" + "net/http" + "net/http/httptest" + "os" "testing" "time" @@ -30,10 +34,17 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/api" apinode "github.com/retr0h/osapi/internal/api/node" "github.com/retr0h/osapi/internal/api/node/gen" + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" jobtypes "github.com/retr0h/osapi/internal/job" jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/provider/node/disk" + "github.com/retr0h/osapi/internal/provider/node/host" + "github.com/retr0h/osapi/internal/provider/node/load" + "github.com/retr0h/osapi/internal/provider/node/mem" "github.com/retr0h/osapi/internal/validation" ) @@ -44,6 +55,8 @@ type NodeStatusGetPublicTestSuite struct { mockJobClient *jobmocks.MockJobClient handler *apinode.Node ctx context.Context + appConfig config.Config + logger *slog.Logger } func (s *NodeStatusGetPublicTestSuite) SetupSuite() { @@ -60,6 +73,8 @@ func (s *NodeStatusGetPublicTestSuite) SetupTest() { s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) s.handler = apinode.New(slog.Default(), s.mockJobClient) s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) } func (s *NodeStatusGetPublicTestSuite) TearDownTest() { @@ -181,6 +196,274 @@ func (s *NodeStatusGetPublicTestSuite) TestGetNodeStatus() { } } +func (s *NodeStatusGetPublicTestSuite) TestGetNodeStatusHTTP() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantBody string + wantContains []string + }{ + { + name: "when get Ok", + path: "/node/server1", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNodeStatus(gomock.Any(), "server1"). + Return("550e8400-e29b-41d4-a716-446655440000", &jobtypes.NodeStatusResponse{ + Hostname: "default-hostname", + Uptime: 5 * time.Hour, + OSInfo: &host.OSInfo{ + Distribution: "Ubuntu", + Version: "24.04", + }, + LoadAverages: &load.AverageStats{ + Load1: 1, + Load5: 0.5, + Load15: 0.2, + }, + MemoryStats: &mem.Stats{ + Total: 8388608, + Free: 4194304, + Cached: 2097152, + }, + DiskUsage: []disk.UsageStats{ + { + Name: "/dev/disk1", + Total: 500000000000, + Used: 250000000000, + Free: 250000000000, + }, + }, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantBody: ` +{ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "results": [ + { + "disks": [ + { + "free": 250000000000, + "name": "/dev/disk1", + "total": 500000000000, + "used": 250000000000 + } + ], + "hostname": "default-hostname", + "load_average": { + "1min": 1, + "5min": 0.5, + "15min": 0.2 + }, + "memory": { + "free": 4194304, + "total": 8388608, + "used": 2097152 + }, + "os_info": { + "distribution": "Ubuntu", + "version": "24.04" + }, + "uptime": "0 days, 5 hours, 0 minutes" + } + ] +} +`, + }, + { + name: "when job client errors", + path: "/node/server1", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNodeStatus(gomock.Any(), "server1"). + Return("", nil, assert.AnError) + return mock + }, + wantCode: http.StatusInternalServerError, + wantBody: `{"error":"assert.AnError general error for testing"}`, + }, + { + name: "when broadcast all", + path: "/node/_all", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNodeStatusBroadcast(gomock.Any(), "_all"). + Return("550e8400-e29b-41d4-a716-446655440000", []*jobtypes.NodeStatusResponse{ + { + Hostname: "server1", + Uptime: time.Hour, + }, + { + Hostname: "server2", + Uptime: 2 * time.Hour, + }, + }, map[string]string{}, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"server1"`, `"server2"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + nodeHandler := apinode.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(nodeHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + if tc.wantBody != "" { + s.JSONEq(tc.wantBody, rec.Body.String()) + } + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacStatusTestSigningKey = "test-signing-key-for-rbac-integration" + +func (s *NodeStatusGetPublicTestSuite) TestGetNodeStatusRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacStatusTestSigningKey, + []string{"read"}, + "test-user", + []string{"job:read"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with node:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacStatusTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + QueryNodeStatus(gomock.Any(), "server1"). + Return( + "550e8400-e29b-41d4-a716-446655440000", + &jobtypes.NodeStatusResponse{ + Hostname: "default-hostname", + Uptime: 5 * time.Hour, + OSInfo: &host.OSInfo{ + Distribution: "Ubuntu", + Version: "24.04", + }, + LoadAverages: &load.AverageStats{ + Load1: 1, + Load5: 0.5, + Load15: 0.2, + }, + MemoryStats: &mem.Stats{ + Total: 8388608, + Free: 4194304, + Cached: 2097152, + }, + DiskUsage: []disk.UsageStats{ + { + Name: "/dev/disk1", + Total: 500000000000, + Used: 250000000000, + Free: 250000000000, + }, + }, + }, + nil, + ) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"hostname":"default-hostname"`, `"job_id"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + API: config.API{ + Server: config.Server{ + Security: config.ServerSecurity{ + SigningKey: rbacStatusTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := server.GetNodeHandler(jobMock) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest(http.MethodGet, "/node/server1", nil) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + func TestNodeStatusGetPublicTestSuite(t *testing.T) { suite.Run(t, new(NodeStatusGetPublicTestSuite)) } diff --git a/justfile b/justfile index da62a29ce..30d425939 100644 --- a/justfile +++ b/justfile @@ -2,7 +2,6 @@ # Recipes below use `just` subcommands instead of dependency syntax because just # validates dependencies at parse time, which would fail when modules aren't loaded. mod? go '.just/remote/go.mod.just' -mod? bats '.just/remote/bats.mod.just' mod? docs '.just/remote/docs.mod.just' # --- Fetch --- @@ -12,8 +11,6 @@ fetch: mkdir -p .just/remote curl -sSfL https://raw.githubusercontent.com/osapi-io/osapi-io-justfiles/refs/heads/main/go.mod.just -o .just/remote/go.mod.just curl -sSfL https://raw.githubusercontent.com/osapi-io/osapi-io-justfiles/refs/heads/main/go.just -o .just/remote/go.just - curl -sSfL https://raw.githubusercontent.com/osapi-io/osapi-io-justfiles/refs/heads/main/bats.mod.just -o .just/remote/bats.mod.just - curl -sSfL https://raw.githubusercontent.com/osapi-io/osapi-io-justfiles/refs/heads/main/bats.just -o .just/remote/bats.just curl -sSfL https://raw.githubusercontent.com/osapi-io/osapi-io-justfiles/refs/heads/main/docs.mod.just -o .just/remote/docs.mod.just curl -sSfL https://raw.githubusercontent.com/osapi-io/osapi-io-justfiles/refs/heads/main/docs.just -o .just/remote/docs.just @@ -23,20 +20,15 @@ fetch: deps: just go::deps just go::mod - just bats::deps just docs::deps # Run all tests -test: linux-tune _bats-clean +test: linux-tune just go::test - just bats::test - -[private] -_bats-clean: - rm -f database.db # Generate code generate: + redocly join --prefix-tags-with-info-prop title -o internal/api/gen/api.yaml internal/api/*/gen/api.yaml just go::generate just docs::generate diff --git a/test/cli_client_network_integration_test.bats b/test/cli_client_network_integration_test.bats deleted file mode 100644 index 9fb04a153..000000000 --- a/test/cli_client_network_integration_test.bats +++ /dev/null @@ -1,51 +0,0 @@ -# Copyright (c) 2024 John Dewey - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. - -load "libs/common.bash" - -setup() { - start_server -} - -teardown() { - stop_server -} - -@test "invoke client node network dns get subcommand" { - run go run ${PROGRAM} client node network dns get --interface-name eth0 - - [ "$status" -eq 0 ] -} - -@test "invoke client node network dns update subcommand" { - run go run ${PROGRAM} client node network dns update \ - --servers "1.1.1.1,8.8.8.8" \ - --search-domains "foo.bar,baz.qux" \ - --interface-name eth0 - - [ "$status" -eq 0 ] -} - -@test "invoke client node network ping subcommand" { - run go run ${PROGRAM} client node network ping \ - --address "127.0.0.1" - - [ "$status" -eq 0 ] -} diff --git a/test/cli_client_system_integration_test.bats b/test/cli_client_system_integration_test.bats deleted file mode 100644 index 417c3c641..000000000 --- a/test/cli_client_system_integration_test.bats +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright (c) 2024 John Dewey - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. - -load "libs/common.bash" - -setup() { - start_server -} - -teardown() { - stop_server -} - -@test "invoke client system hostname get subcommand" { - run go run ${PROGRAM} client system hostname get - - [ "$status" -eq 0 ] -} - -@test "invoke client system status subcommand" { - run go run ${PROGRAM} client system status - - [ "$status" -eq 0 ] -} diff --git a/test/cli_flags.bats b/test/cli_flags.bats deleted file mode 100644 index c738f6066..000000000 --- a/test/cli_flags.bats +++ /dev/null @@ -1,33 +0,0 @@ -# Copyright (c) 2024 John Dewey - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. - -load "libs/common.bash" - -@test "invoke without arguments prints usage" { - run go run ${PROGRAM} help - - echo "$output" - - [ "$status" -eq 0 ] -} - -@test "invoke version subcommand" { - skip "TODO test is pending implementation" -} diff --git a/test/fixtures/network_dns_query.json b/test/fixtures/network_dns_query.json deleted file mode 100644 index ef9d0ec56..000000000 --- a/test/fixtures/network_dns_query.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "network.dns.get", - "data": { - "interface": "eth0" - } -} \ No newline at end of file diff --git a/test/fixtures/network_dns_update.json b/test/fixtures/network_dns_update.json deleted file mode 100644 index 75236646c..000000000 --- a/test/fixtures/network_dns_update.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "type": "network.dns.update", - "data": { - "dns_servers": ["8.8.8.8", "8.8.4.4"], - "search_domains": ["example.com"], - "interface": "eth0" - } -} \ No newline at end of file diff --git a/test/fixtures/network_ping_do.json b/test/fixtures/network_ping_do.json deleted file mode 100644 index fca449b35..000000000 --- a/test/fixtures/network_ping_do.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "type": "network.ping.do", - "data": { - "address": "8.8.8.8" - } -} \ No newline at end of file diff --git a/test/fixtures/system_disk_get.json b/test/fixtures/system_disk_get.json deleted file mode 100644 index be9653d0e..000000000 --- a/test/fixtures/system_disk_get.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "system.disk.get", - "data": {} -} \ No newline at end of file diff --git a/test/fixtures/system_hostname_get.json b/test/fixtures/system_hostname_get.json deleted file mode 100644 index 4f9f49e1a..000000000 --- a/test/fixtures/system_hostname_get.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "system.hostname.get", - "data": {} -} \ No newline at end of file diff --git a/test/fixtures/system_load_get.json b/test/fixtures/system_load_get.json deleted file mode 100644 index 7c291684b..000000000 --- a/test/fixtures/system_load_get.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "system.load.get", - "data": {} -} \ No newline at end of file diff --git a/test/fixtures/system_memory_get.json b/test/fixtures/system_memory_get.json deleted file mode 100644 index 47ae910bb..000000000 --- a/test/fixtures/system_memory_get.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "system.memory.get", - "data": {} -} \ No newline at end of file diff --git a/test/fixtures/system_os_get.json b/test/fixtures/system_os_get.json deleted file mode 100644 index 675477934..000000000 --- a/test/fixtures/system_os_get.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "system.os.get", - "data": {} -} \ No newline at end of file diff --git a/test/fixtures/system_status.json b/test/fixtures/system_status.json deleted file mode 100644 index f8c7bd93a..000000000 --- a/test/fixtures/system_status.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "system.status.get", - "data": {} -} \ No newline at end of file diff --git a/test/fixtures/system_uptime_get.json b/test/fixtures/system_uptime_get.json deleted file mode 100644 index 3f5c9dfe7..000000000 --- a/test/fixtures/system_uptime_get.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "type": "system.uptime.get", - "data": {} -} \ No newline at end of file diff --git a/test/integration/agent_test.go b/test/integration/agent_test.go new file mode 100644 index 000000000..82d89cd80 --- /dev/null +++ b/test/integration/agent_test.go @@ -0,0 +1,116 @@ +//go:build integration + +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package integration_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type AgentSmokeSuite struct { + suite.Suite +} + +func (s *AgentSmokeSuite) TestAgentList() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns at least one agent", + args: []string{"client", "agent", "list", "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var resp struct { + Agents []struct { + Hostname string `json:"hostname"` + } `json:"agents"` + } + s.Require().NoError(parseJSON(stdout, &resp)) + s.GreaterOrEqual(len(resp.Agents), 1) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *AgentSmokeSuite) TestAgentGet() { + listOut, _, listCode := runCLI("client", "agent", "list", "--json") + s.Require().Equal(0, listCode) + + var listResp struct { + Agents []struct { + Hostname string `json:"hostname"` + } `json:"agents"` + } + s.Require().NoError(parseJSON(listOut, &listResp)) + s.Require().NotEmpty(listResp.Agents, "agent list must contain at least one entry") + + hostname := listResp.Agents[0].Hostname + + tests := []struct { + name string + args []string + validateFunc func(stdout string) + }{ + { + name: "returns agent details for known hostname", + args: []string{"client", "agent", "get", "--hostname", hostname, "--json"}, + validateFunc: func(stdout string) { + var resp struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + } + s.Require().NoError(parseJSON(stdout, &resp)) + s.Equal(hostname, resp.Hostname) + s.Equal("Ready", resp.Status) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + s.Require().Equal(0, exitCode) + tt.validateFunc(stdout) + }) + } +} + +func TestAgentSmokeSuite( + t *testing.T, +) { + suite.Run(t, new(AgentSmokeSuite)) +} diff --git a/test/integration/audit_test.go b/test/integration/audit_test.go new file mode 100644 index 000000000..fd4d487cd --- /dev/null +++ b/test/integration/audit_test.go @@ -0,0 +1,151 @@ +//go:build integration + +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package integration_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/suite" +) + +type AuditSmokeSuite struct { + suite.Suite +} + +func (s *AuditSmokeSuite) TestAuditList() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns audit entries list", + args: []string{"client", "audit", "list", "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + s.Require().NoError(parseJSON(stdout, &result)) + s.Contains(result, "items") + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *AuditSmokeSuite) TestAuditExport() { + exportPath := filepath.Join(tempDir, "audit-export.json") + + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "exports audit log to file", + args: []string{"client", "audit", "export", "--output", exportPath, "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + info, err := os.Stat(exportPath) + s.Require().NoError(err) + s.Greater(info.Size(), int64(0)) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *AuditSmokeSuite) TestAuditGet() { + listOut, _, listCode := runCLI("client", "audit", "list", "--json") + s.Require().Equal(0, listCode) + + var listResp struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + s.Require().NoError(parseJSON(listOut, &listResp)) + s.Require().NotEmpty(listResp.Items, "audit list must contain at least one entry") + + auditID := listResp.Items[0].ID + + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns audit entry details for known id", + args: []string{"client", "audit", "get", "--audit-id", auditID, "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result struct { + Entry struct { + ID string `json:"id"` + User string `json:"user"` + } `json:"entry"` + } + s.Require().NoError(parseJSON(stdout, &result)) + s.Equal(auditID, result.Entry.ID) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func TestAuditSmokeSuite( + t *testing.T, +) { + suite.Run(t, new(AuditSmokeSuite)) +} diff --git a/test/integration/command_test.go b/test/integration/command_test.go new file mode 100644 index 000000000..373a042c5 --- /dev/null +++ b/test/integration/command_test.go @@ -0,0 +1,126 @@ +//go:build integration + +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package integration_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type CommandSmokeSuite struct { + suite.Suite +} + +func (s *CommandSmokeSuite) TestCommandExec() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns exec results with stdout and exit code", + args: []string{ + "client", "node", "command", "exec", + "--command", "echo", + "--args", "hello", + "--json", + }, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + + results, ok := result["results"].([]any) + s.Require().True(ok) + s.GreaterOrEqual(len(results), 1) + + first, ok := results[0].(map[string]any) + s.Require().True(ok) + s.Contains(first["stdout"].(string), "hello") + s.Equal(float64(0), first["exit_code"]) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *CommandSmokeSuite) TestCommandShell() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns shell results with stdout and exit code", + args: []string{ + "client", "node", "command", "shell", + "--command", "echo hello", + "--json", + }, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + + results, ok := result["results"].([]any) + s.Require().True(ok) + s.GreaterOrEqual(len(results), 1) + + first, ok := results[0].(map[string]any) + s.Require().True(ok) + s.Contains(first["stdout"].(string), "hello") + s.Equal(float64(0), first["exit_code"]) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func TestCommandSmokeSuite( + t *testing.T, +) { + suite.Run(t, new(CommandSmokeSuite)) +} diff --git a/test/integration/health_test.go b/test/integration/health_test.go new file mode 100644 index 000000000..89661922f --- /dev/null +++ b/test/integration/health_test.go @@ -0,0 +1,133 @@ +//go:build integration + +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package integration_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type HealthSmokeSuite struct { + suite.Suite +} + +func (s *HealthSmokeSuite) TestHealthLiveness() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns liveness status", + args: []string{"client", "health", "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + s.Equal("ok", result["status"]) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *HealthSmokeSuite) TestHealthReady() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns ok status", + args: []string{"client", "health", "ready", "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + s.Equal("ready", result["status"]) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *HealthSmokeSuite) TestHealthStatus() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns ok status with components", + args: []string{"client", "health", "status", "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + s.Equal("ok", result["status"]) + s.Contains(result, "components") + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func TestHealthSmokeSuite( + t *testing.T, +) { + suite.Run(t, new(HealthSmokeSuite)) +} diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go new file mode 100644 index 000000000..d3f7dac29 --- /dev/null +++ b/test/integration/integration_test.go @@ -0,0 +1,256 @@ +//go:build integration + +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package integration_test + +import ( + "bytes" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/retr0h/osapi/internal/authtoken" +) + +const signingKey = "111fdb0cfd9788fa6af8815f856a0374bf7a0174ad62fa8b98ec07a55f68d8d8" + +var ( + binaryPath string + apiPort int + natsPort int + token string + configPath string + serverCmd *exec.Cmd + tempDir string + storeDir string + runWrites bool +) + +func TestMain( + m *testing.M, +) { + var err error + + runWrites = os.Getenv("OSAPI_INTEGRATION_WRITES") == "1" + + tempDir, err = os.MkdirTemp("", "osapi-integration-*") + if err != nil { + fmt.Fprintf(os.Stderr, "create temp dir: %v\n", err) + os.Exit(1) + } + + storeDir = filepath.Join(tempDir, "jetstream") + + apiPort, err = getFreePort() + if err != nil { + fmt.Fprintf(os.Stderr, "get free api port: %v\n", err) + os.Exit(1) + } + + natsPort, err = getFreePort() + if err != nil { + fmt.Fprintf(os.Stderr, "get free nats port: %v\n", err) + os.Exit(1) + } + + binaryPath = filepath.Join(tempDir, "osapi") + buildCmd := exec.Command("go", "build", "-o", binaryPath, ".") + buildCmd.Dir = repoRoot() + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + if err := buildCmd.Run(); err != nil { + fmt.Fprintf(os.Stderr, "build binary: %v\n", err) + os.Exit(1) + } + + t := authtoken.New(nil) + token, err = t.Generate(signingKey, []string{"admin"}, "integration@test", nil) + if err != nil { + fmt.Fprintf(os.Stderr, "generate token: %v\n", err) + os.Exit(1) + } + + configPath, err = filepath.Abs(filepath.Join(repoRoot(), "test", "integration", "osapi.yaml")) + if err != nil { + fmt.Fprintf(os.Stderr, "resolve config path: %v\n", err) + os.Exit(1) + } + + fmt.Fprintf(os.Stderr, "integration: api=%d nats=%d dir=%s\n", apiPort, natsPort, tempDir) + + serverCmd = exec.Command(binaryPath, "start", "-f", configPath) + serverCmd.Env = serverEnv() + serverCmd.Stdout = os.Stdout + serverCmd.Stderr = os.Stderr + if err := serverCmd.Start(); err != nil { + fmt.Fprintf(os.Stderr, "start server: %v\n", err) + os.Exit(1) + } + + if err := waitForReady(15 * time.Second); err != nil { + fmt.Fprintf(os.Stderr, "wait for ready: %v\n", err) + stopServer() + os.Exit(1) + } + + code := m.Run() + + stopServer() + os.RemoveAll(tempDir) + os.Exit(code) +} + +func getFreePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, fmt.Errorf("listen for free port: %w", err) + } + defer l.Close() + + addr, ok := l.Addr().(*net.TCPAddr) + if !ok { + return 0, fmt.Errorf("unexpected addr type: %T", l.Addr()) + } + + return addr.Port, nil +} + +func repoRoot() string { + wd, err := os.Getwd() + if err != nil { + panic(fmt.Sprintf("get working directory: %v", err)) + } + + // test/integration/ is two levels below repo root + return filepath.Join(wd, "..", "..") +} + +func serverEnv() []string { + return append(os.Environ(), + fmt.Sprintf("OSAPI_NATS_SERVER_PORT=%d", natsPort), + fmt.Sprintf("OSAPI_NATS_SERVER_STORE_DIR=%s", storeDir), + fmt.Sprintf("OSAPI_API_SERVER_PORT=%d", apiPort), + fmt.Sprintf("OSAPI_API_SERVER_NATS_PORT=%d", natsPort), + fmt.Sprintf("OSAPI_AGENT_NATS_PORT=%d", natsPort), + fmt.Sprintf("OSAPI_API_CLIENT_SECURITY_BEARER_TOKEN=%s", token), + ) +} + +func clientEnv() []string { + return append(os.Environ(), + fmt.Sprintf("OSAPI_API_CLIENT_URL=http://127.0.0.1:%d", apiPort), + fmt.Sprintf("OSAPI_API_CLIENT_SECURITY_BEARER_TOKEN=%s", token), + ) +} + +func waitForReady( + timeout time.Duration, +) error { + deadline := time.Now().Add(timeout) + url := fmt.Sprintf("http://127.0.0.1:%d/health/ready", apiPort) + + for time.Now().Before(deadline) { + resp, err := http.Get(url) //nolint:gosec + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return nil + } + } + time.Sleep(500 * time.Millisecond) + } + + return fmt.Errorf("server not ready after %s", timeout) +} + +func stopServer() { + if serverCmd != nil && serverCmd.Process != nil { + _ = serverCmd.Process.Kill() + _ = serverCmd.Wait() + } +} + +func runCLI( + args ...string, +) (string, string, int) { + fullArgs := append([]string{"-f", configPath}, args...) + cmd := exec.Command(binaryPath, fullArgs...) + cmd.Env = clientEnv() + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + } + + return stdout.String(), stderr.String(), exitCode +} + +func parseJSON( + raw string, + target any, +) error { + return json.Unmarshal([]byte(strings.TrimSpace(raw)), target) +} + +func skipWrite( + t *testing.T, +) { + t.Helper() + if !runWrites { + t.Skip("skipping write test (set OSAPI_INTEGRATION_WRITES=1 to enable)") + } +} + +func writeJobFile( + t *testing.T, + data map[string]any, +) string { + t.Helper() + + b, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal job file: %v", err) + } + + path := filepath.Join(t.TempDir(), "job.json") + if err := os.WriteFile(path, b, 0o644); err != nil { + t.Fatalf("write job file: %v", err) + } + + return path +} diff --git a/test/integration/job_test.go b/test/integration/job_test.go new file mode 100644 index 000000000..9b408e8e3 --- /dev/null +++ b/test/integration/job_test.go @@ -0,0 +1,314 @@ +//go:build integration + +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package integration_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type JobSmokeSuite struct { + suite.Suite +} + +func (s *JobSmokeSuite) TestJobList() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns jobs list", + args: []string{"client", "job", "list", "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + s.Require().NoError(parseJSON(stdout, &result)) + s.Contains(result, "jobs") + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *JobSmokeSuite) TestJobGet() { + triggerOut, _, triggerCode := runCLI( + "client", "node", "command", "shell", + "--command", "echo job-test", + "--json", + ) + s.Require().Equal(0, triggerCode) + + var triggerResp struct { + JobID string `json:"job_id"` + } + s.Require().NoError(parseJSON(triggerOut, &triggerResp)) + s.Require().NotEmpty(triggerResp.JobID, "shell command must return a job_id") + + jobID := triggerResp.JobID + + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns job details for known job id", + args: []string{"client", "job", "get", "--job-id", jobID, "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result struct { + ID string `json:"id"` + } + s.Require().NoError(parseJSON(stdout, &result)) + s.Equal(jobID, result.ID) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *JobSmokeSuite) TestJobStatus() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns queue stats with total_jobs", + args: []string{"client", "job", "status", "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + s.Require().NoError(parseJSON(stdout, &result)) + s.Contains(result, "total_jobs") + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *JobSmokeSuite) TestJobDelete() { + skipWrite(s.T()) + + triggerOut, _, triggerCode := runCLI( + "client", "node", "command", "shell", + "--command", "echo delete-test", + "--json", + ) + s.Require().Equal(0, triggerCode) + + var triggerResp struct { + JobID string `json:"job_id"` + } + s.Require().NoError(parseJSON(triggerOut, &triggerResp)) + s.Require().NotEmpty(triggerResp.JobID) + + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "deletes a job by id", + args: []string{"client", "job", "delete", "--job-id", triggerResp.JobID, "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *JobSmokeSuite) TestJobAdd() { + skipWrite(s.T()) + + jobFile := writeJobFile(s.T(), map[string]any{ + "operation": "node.hostname", + "target": "_any", + }) + + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "adds a job to the queue", + args: []string{"client", "job", "add", "--json-file", jobFile, "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result struct { + JobID string `json:"job_id"` + Status string `json:"status"` + } + s.Require().NoError(parseJSON(stdout, &result)) + s.NotEmpty(result.JobID) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *JobSmokeSuite) TestJobRun() { + skipWrite(s.T()) + + jobFile := writeJobFile(s.T(), map[string]any{ + "operation": "node.hostname", + "target": "_any", + }) + + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "submits and polls job to completion", + args: []string{ + "client", "job", "run", + "--json-file", jobFile, + "--timeout", "30", + "--poll-interval", "1", + "--json", + }, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result struct { + ID string `json:"id"` + Status *string `json:"status"` + } + s.Require().NoError(parseJSON(stdout, &result)) + s.NotEmpty(result.ID) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *JobSmokeSuite) TestJobRetry() { + skipWrite(s.T()) + + triggerOut, _, triggerCode := runCLI( + "client", "node", "command", "shell", + "--command", "echo retry-test", + "--json", + ) + s.Require().Equal(0, triggerCode) + + var triggerResp struct { + JobID string `json:"job_id"` + } + s.Require().NoError(parseJSON(triggerOut, &triggerResp)) + s.Require().NotEmpty(triggerResp.JobID) + + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "retries a job by id", + args: []string{"client", "job", "retry", "--job-id", triggerResp.JobID, "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func TestJobSmokeSuite( + t *testing.T, +) { + suite.Run(t, new(JobSmokeSuite)) +} diff --git a/test/integration/metrics_test.go b/test/integration/metrics_test.go new file mode 100644 index 000000000..c174702fd --- /dev/null +++ b/test/integration/metrics_test.go @@ -0,0 +1,67 @@ +//go:build integration + +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package integration_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type MetricsSmokeSuite struct { + suite.Suite +} + +func (s *MetricsSmokeSuite) TestMetricsGet() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns prometheus metrics text", + args: []string{"client", "metrics"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + s.Contains(stdout, "# HELP") + s.Contains(stdout, "# TYPE") + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func TestMetricsSmokeSuite( + t *testing.T, +) { + suite.Run(t, new(MetricsSmokeSuite)) +} diff --git a/test/integration/network_test.go b/test/integration/network_test.go new file mode 100644 index 000000000..f2741d4de --- /dev/null +++ b/test/integration/network_test.go @@ -0,0 +1,165 @@ +//go:build integration + +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package integration_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type NetworkSmokeSuite struct { + suite.Suite +} + +func (s *NetworkSmokeSuite) TestNetworkDnsGet() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns dns results", + args: []string{ + "client", + "node", + "network", + "dns", + "get", + "--interface-name", + "eth0", + "--json", + }, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + + results, ok := result["results"].([]any) + s.Require().True(ok) + s.GreaterOrEqual(len(results), 1) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *NetworkSmokeSuite) TestNetworkDnsUpdate() { + skipWrite(s.T()) + + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns dns update results", + args: []string{ + "client", + "node", + "network", + "dns", + "update", + "--interface-name", + "eth0", + "--servers", + "1.1.1.1,8.8.8.8", + "--search-domains", + "example.com", + "--json", + }, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + + results, ok := result["results"].([]any) + s.Require().True(ok) + s.GreaterOrEqual(len(results), 1) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *NetworkSmokeSuite) TestNetworkPingPost() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns ping results", + args: []string{"client", "node", "network", "ping", "--address", "127.0.0.1", "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + + results, ok := result["results"].([]any) + s.Require().True(ok) + s.GreaterOrEqual(len(results), 1) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func TestNetworkSmokeSuite( + t *testing.T, +) { + suite.Run(t, new(NetworkSmokeSuite)) +} diff --git a/test/integration/node_test.go b/test/integration/node_test.go new file mode 100644 index 000000000..36a8f0c05 --- /dev/null +++ b/test/integration/node_test.go @@ -0,0 +1,111 @@ +//go:build integration + +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package integration_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type NodeSmokeSuite struct { + suite.Suite +} + +func (s *NodeSmokeSuite) TestNodeHostnameGet() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns hostname results", + args: []string{"client", "node", "hostname", "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + + results, ok := result["results"].([]any) + s.Require().True(ok) + s.GreaterOrEqual(len(results), 1) + + first, ok := results[0].(map[string]any) + s.Require().True(ok) + s.NotEmpty(first["hostname"]) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func (s *NodeSmokeSuite) TestNodeStatusGet() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "returns status results", + args: []string{"client", "node", "status", "--json"}, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + + results, ok := result["results"].([]any) + s.Require().True(ok) + s.GreaterOrEqual(len(results), 1) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func TestNodeSmokeSuite( + t *testing.T, +) { + suite.Run(t, new(NodeSmokeSuite)) +} diff --git a/test/osapi.yaml b/test/integration/osapi.yaml similarity index 68% rename from test/osapi.yaml rename to test/integration/osapi.yaml index 0eb6a2f77..cf60b36ec 100644 --- a/test/osapi.yaml +++ b/test/integration/osapi.yaml @@ -3,24 +3,22 @@ debug: false api: client: - url: http://0.0.0.0:8080 + url: http://127.0.0.1:8080 security: - bearer_token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJhZG1pbiJdLCJpc3MiOiJvc2FwaSIsInN1YiI6InRlc3RAY2kiLCJhdWQiOlsiaHR0cHM6Ly9sb2NhbGhvc3QiLCJodHRwOi8vbG9jYWxob3N0Il0sImV4cCI6MTc3ODk2NDQ0NiwiaWF0IjoxNzcxMjc4NDQ2fQ.d9Rh86HUON7eXCL5l3LKiFhakkXXK6kGHnU8ZXIgQ-c + bearer_token: placeholder server: port: 8080 nats: host: localhost port: 4222 - client_name: osapi-api-test + client_name: osapi-api-integration namespace: "" auth: type: none security: signing_key: 111fdb0cfd9788fa6af8815f856a0374bf7a0174ad62fa8b98ec07a55f68d8d8 cors: - allow_origins: - - http://localhost:3001 - - https://osapi-io.github.io + allow_origins: [] nats: server: @@ -36,7 +34,7 @@ nats: subjects: jobs.> max_age: 24h max_msgs: 10000 - storage: file + storage: memory replicas: 1 discard: old @@ -45,33 +43,33 @@ nats: response_bucket: job-responses ttl: 1h max_bytes: 104857600 - storage: file + storage: memory replicas: 1 audit: bucket: audit-log ttl: 720h max_bytes: 52428800 - storage: file + storage: memory replicas: 1 registry: bucket: agent-registry ttl: 30s - storage: file + storage: memory replicas: 1 dlq: max_age: 7d max_msgs: 1000 - storage: file + storage: memory replicas: 1 agent: nats: host: localhost port: 4222 - client_name: osapi-agent-test + client_name: osapi-agent-integration namespace: "" auth: type: none diff --git a/test/libs/common.bash b/test/libs/common.bash deleted file mode 100644 index df14af92e..000000000 --- a/test/libs/common.bash +++ /dev/null @@ -1,56 +0,0 @@ -# Copyright (c) 2024 John Dewey - -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to -# deal in the Software without restriction, including without limitation the -# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -# sell copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: - -# The above copyright notice and this permission notice shall be included in -# all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -# DEALINGS IN THE SOFTWARE. - -PROGRAM="../main.go" -BATS_TEST_TIMEOUT=60 -CONFIG="osapi.yaml" -export OSAPI_OSAPIFILE="${CONFIG}" - -# Function to start the server -start_server() { - # Start embedded NATS server (replaces external nats-server binary) - go run ${PROGRAM} nats server start & - sleep 2 - - # Generate fresh admin token and update config - TOKEN=$(go run ${PROGRAM} -j token generate \ - -r admin -u test@ci 2>/dev/null \ - | sed -n 's/.*"token":"\([^"]*\)".*/\1/p') - if [ -n "${TOKEN}" ]; then - sed -i.bak "s|bearer_token:.*|bearer_token: ${TOKEN}|" "${CONFIG}" - rm -f "${CONFIG}.bak" - fi - - # Start API server - go run ${PROGRAM} api server start & - sleep 2 - - # Start node agent - go run ${PROGRAM} node agent start & - sleep 3 -} - -# Function to stop the server -stop_server() { - pkill -f "api server start" || true - pkill -f "node agent start" || true - pkill -f "nats server start" || true - rm -rf .nats/ -}