package api import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "github.com/alexei/tinyforge/internal/auth" "github.com/alexei/tinyforge/internal/crypto" "github.com/alexei/tinyforge/internal/store" "github.com/alexei/tinyforge/internal/webhook" "github.com/alexei/tinyforge/internal/workload/plugin" // Blank-imports register the source/trigger plugins the tests assert // against. Mirrors cmd/server/main.go's set. _ "github.com/alexei/tinyforge/internal/workload/plugin/source/image" _ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/git" _ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/manual" _ "github.com/alexei/tinyforge/internal/workload/plugin/trigger/registry" ) // ============================================================================= // Test helpers // ============================================================================= // fakeAPIDispatcher is the minimum PluginDispatcher the API needs. It // counts Deploy / Teardown calls so handlers can be observed end-to-end. // Returning nil errors keeps the tests focused on HTTP/store behaviour; // per-test errFn override flips that on demand. type fakeAPIDispatcher struct { deployCount atomic.Int32 teardownCount atomic.Int32 lastIntent atomic.Pointer[plugin.DeploymentIntent] lastWorkID atomic.Value // string deployErrFn func() error teardownErrFn func() error } func (f *fakeAPIDispatcher) DispatchPlugin(_ context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error { f.deployCount.Add(1) f.lastIntent.Store(&intent) f.lastWorkID.Store(w.ID) if f.deployErrFn != nil { return f.deployErrFn() } return nil } func (f *fakeAPIDispatcher) DispatchTeardown(_ context.Context, w plugin.Workload) error { f.teardownCount.Add(1) f.lastWorkID.Store(w.ID) if f.teardownErrFn != nil { return f.teardownErrFn() } return nil } func (f *fakeAPIDispatcher) PluginDeps() plugin.Deps { return plugin.Deps{} } // apiTestEnv bundles everything a test needs: a live test server, the // underlying store for asserting persistence, the fake dispatcher for // observing dispatch calls, and an admin token for hitting protected routes. type apiTestEnv struct { srv *httptest.Server store *store.Store dispatcher *fakeAPIDispatcher adminToken string encKey [32]byte } func (e *apiTestEnv) close() { e.srv.Close() } // newAPITestEnv spins up an in-memory store, a fake dispatcher, a webhook // handler bound to the dispatcher, and the API server. An admin user is // created and a valid JWT minted so authenticated routes can be exercised. func newAPITestEnv(t *testing.T) *apiTestEnv { t.Helper() st, err := store.New(":memory:") if err != nil { t.Fatalf("create store: %v", err) } t.Cleanup(func() { st.Close() }) encKey := [32]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10} dispatcher := &fakeAPIDispatcher{} wh := webhook.NewHandler(st) wh.SetPluginDispatcher(dispatcher) srv := NewServer( st, nil, // dockerClient — unused on the routes under test nil, // npmClient nil, // proxyProvider dispatcher, nil, // notifier wh, nil, // eventBus encKey, ) httpsrv := httptest.NewServer(srv.Router()) t.Cleanup(httpsrv.Close) // Mint an admin token via the same auth.LocalAuth instance the server uses. // The router constructs LocalAuth from encKey internally; rebuilding one // here with the same key produces a token the server's middleware // accepts. la := auth.NewLocalAuth(encKey) tok, err := la.GenerateToken(auth.Claims{ UserID: "u-admin", Username: "admin", Role: "admin", }) if err != nil { t.Fatalf("mint token: %v", err) } return &apiTestEnv{ srv: httpsrv, store: st, dispatcher: dispatcher, adminToken: tok.Token, encKey: encKey, } } // do issues an authenticated request and returns the response. Failures // to construct the request are fatal because they are bugs in the test // itself, not the system under test. func (e *apiTestEnv) do(t *testing.T, method, path string, body any) *http.Response { t.Helper() var rdr io.Reader if body != nil { b, err := json.Marshal(body) if err != nil { t.Fatalf("marshal body: %v", err) } rdr = bytes.NewReader(b) } req, err := http.NewRequest(method, e.srv.URL+path, rdr) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("Authorization", "Bearer "+e.adminToken) if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do request: %v", err) } return resp } // decodeEnvelope reads the response body into the standard {success,data,error} // envelope and decodes data into out. Fatals on any error — tests should // already have asserted the status code separately. func decodeEnvelope(t *testing.T, resp *http.Response, out any) string { t.Helper() defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } var env struct { Success bool `json:"success"` Data json.RawMessage `json:"data"` Error string `json:"error"` } if err := json.Unmarshal(body, &env); err != nil { t.Fatalf("unmarshal envelope: %v\nbody=%s", err, string(body)) } if out != nil && len(env.Data) > 0 { if err := json.Unmarshal(env.Data, out); err != nil { t.Fatalf("unmarshal data: %v\ndata=%s", err, string(env.Data)) } } return env.Error } // validImageSourceConfig returns the JSON body for a valid image source // config — kept consistent across tests so the create-success cases all // look the same. func validImageSourceConfig() json.RawMessage { return json.RawMessage(`{"image":"registry.example.com/owner/app","port":8080,"default_tag":"latest"}`) } // ============================================================================= // POST /api/workloads — create // ============================================================================= func TestCreateWorkload_HappyPath_ReturnsCreatedRow(t *testing.T) { e := newAPITestEnv(t) body := pluginWorkloadRequest{ Name: "my-app", SourceKind: "image", SourceConfig: validImageSourceConfig(), } resp := e.do(t, http.MethodPost, "/api/workloads", body) if resp.StatusCode != http.StatusCreated { _ = decodeEnvelope(t, resp, nil) t.Fatalf("status = %d, want 201", resp.StatusCode) } var got plugin.Workload if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if got.ID == "" { t.Fatal("expected ID to be assigned") } if got.Name != "my-app" { t.Fatalf("Name = %q, want my-app", got.Name) } // Sanity: the row is persisted in the store with the same ID and the // kind="plugin" sentinel so legacy filters continue to skip it. row, err := e.store.GetWorkloadByID(got.ID) if err != nil { t.Fatalf("GetWorkloadByID: %v", err) } if row.Kind != "plugin" { t.Fatalf("row.Kind = %q, want plugin", row.Kind) } // CreateWorkload self-references RefID to ID for plugin-native rows // so the UNIQUE(kind, ref_id) constraint can hold many siblings. if row.RefID != row.ID { t.Fatalf("RefID = %q, want self-reference to ID %q", row.RefID, row.ID) } } func TestCreateWorkload_ValidationErrors(t *testing.T) { cases := []struct { name string req pluginWorkloadRequest wantCode int wantSub string }{ { name: "empty name", req: pluginWorkloadRequest{Name: " ", SourceKind: "image", SourceConfig: validImageSourceConfig()}, wantCode: http.StatusBadRequest, wantSub: "name is required", }, { name: "unknown source kind", req: pluginWorkloadRequest{Name: "x", SourceKind: "no-such-kind", SourceConfig: json.RawMessage(`{}`)}, wantCode: http.StatusBadRequest, wantSub: "no source registered", }, { name: "unknown trigger kind via inline binding (validateTrigger)", req: pluginWorkloadRequest{Name: "x", SourceKind: "image", SourceConfig: validImageSourceConfig(), TriggerKind: "no-such-trigger", TriggerConfig: json.RawMessage(`{}`)}, wantCode: http.StatusBadRequest, wantSub: "no trigger registered", }, { name: "oversized source config", req: pluginWorkloadRequest{ Name: "x", SourceKind: "image", SourceConfig: json.RawMessage(`{"image":"x","junk":"` + strings.Repeat("A", maxSourceConfigBytes+10) + `"}`), }, wantCode: http.StatusBadRequest, wantSub: "source_config exceeds", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodPost, "/api/workloads", tc.req) defer resp.Body.Close() if resp.StatusCode != tc.wantCode { body, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d, want %d (body=%s)", resp.StatusCode, tc.wantCode, string(body)) } body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), tc.wantSub) { t.Fatalf("body %q missing substring %q", string(body), tc.wantSub) } }) } } // ============================================================================= // GET /api/workloads — list // ============================================================================= func TestListWorkloads_Empty(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodGet, "/api/workloads", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } var got []plugin.Workload if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if len(got) != 0 { t.Fatalf("expected empty list, got %d rows", len(got)) } } func TestListWorkloads_Populated(t *testing.T) { e := newAPITestEnv(t) // Seed via the test helper to avoid the production UNIQUE(kind, ref_id) // quirk on the create handler (see seedWorkload comment). alphaID := seedWorkload(t, e, "alpha") betaID := seedWorkload(t, e, "beta") resp := e.do(t, http.MethodGet, "/api/workloads", nil) var got []plugin.Workload if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } // Assert membership, not just count — a regression that dropped a row // while inserting a duplicate would pass a bare len() check. seen := map[string]bool{} for _, w := range got { seen[w.ID] = true } if !seen[alphaID] || !seen[betaID] { t.Fatalf("list missing seeded ids; got=%v want both %s and %s", got, alphaID, betaID) } if len(got) != 2 { t.Fatalf("expected 2 rows, got %d", len(got)) } } // ============================================================================= // GET /api/workloads/{id} // ============================================================================= func TestGetWorkload_NotFound(t *testing.T) { e := newAPITestEnv(t) resp := e.do(t, http.MethodGet, "/api/workloads/no-such-id", nil) if resp.StatusCode != http.StatusNotFound { t.Fatalf("status = %d, want 404", resp.StatusCode) } } func TestGetWorkload_Found(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "fetch-me") resp := e.do(t, http.MethodGet, "/api/workloads/"+id, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } } // ============================================================================= // PUT /api/workloads/{id}/plugin // ============================================================================= func TestUpdatePluginWorkload_PreservesKindAndUpdatesName(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "before") body := pluginWorkloadRequest{ Name: "after", SourceKind: "image", SourceConfig: validImageSourceConfig(), } resp := e.do(t, http.MethodPut, "/api/workloads/"+id+"/plugin", body) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, string(body)) } resp.Body.Close() row, err := e.store.GetWorkloadByID(id) if err != nil { t.Fatalf("GetWorkloadByID: %v", err) } if row.Name != "after" { t.Fatalf("Name = %q, want after", row.Name) } if row.Kind != "plugin" { t.Fatalf("Kind mutated unexpectedly: %q", row.Kind) } } // ============================================================================= // POST /api/workloads/{id}/deploy // ============================================================================= func TestDeployPluginWorkload_DispatchesManualIntent(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "deploy-me") resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/deploy", map[string]string{ "reference": "v1.2.3", "note": "test deploy", }) if resp.StatusCode != http.StatusAccepted { body, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d, want 202 (body=%s)", resp.StatusCode, string(body)) } resp.Body.Close() if got := e.dispatcher.deployCount.Load(); got != 1 { t.Fatalf("Deploy called %d times, want 1", got) } intent := e.dispatcher.lastIntent.Load() if intent == nil { t.Fatal("dispatcher did not capture intent") } if intent.Reason != "manual" { t.Fatalf("intent.Reason = %q, want manual", intent.Reason) } if intent.Reference != "v1.2.3" { t.Fatalf("intent.Reference = %q, want v1.2.3", intent.Reference) } if intent.TriggeredBy != "admin" { t.Fatalf("intent.TriggeredBy = %q, want admin", intent.TriggeredBy) } } func TestDeployPluginWorkload_RejectsWorkloadWithoutSourceKind(t *testing.T) { e := newAPITestEnv(t) // Build a row directly (bypass the API) with empty SourceKind. row, err := e.store.CreateWorkload(store.Workload{ Kind: "plugin", Name: "no-kind", }) if err != nil { t.Fatalf("seed: %v", err) } resp := e.do(t, http.MethodPost, "/api/workloads/"+row.ID+"/deploy", nil) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } resp.Body.Close() } // ============================================================================= // DELETE /api/workloads/{id} // ============================================================================= func TestDeleteWorkload_CallsTeardownAndDeletes(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "delete-me") resp := e.do(t, http.MethodDelete, "/api/workloads/"+id, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("status = %d, want 200", resp.StatusCode) } resp.Body.Close() if got := e.dispatcher.teardownCount.Load(); got != 1 { t.Fatalf("Teardown called %d times, want 1", got) } if _, err := e.store.GetWorkloadByID(id); err == nil { t.Fatal("expected workload to be deleted from store") } } func TestDeleteWorkload_TeardownErrorDoesNotBlockDelete(t *testing.T) { e := newAPITestEnv(t) e.dispatcher.teardownErrFn = func() error { return fmt.Errorf("teardown blew up") } id := seedWorkload(t, e, "stubborn") resp := e.do(t, http.MethodDelete, "/api/workloads/"+id, nil) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, string(body)) } resp.Body.Close() if _, err := e.store.GetWorkloadByID(id); err == nil { t.Fatal("workload row must be deleted even when teardown fails") } } // ============================================================================= // PATCH /api/workloads/{id}/app // ============================================================================= func TestUpdateWorkloadAppID_SetsAppID(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "with-app") resp := e.do(t, http.MethodPatch, "/api/workloads/"+id+"/app", map[string]string{ "app_id": "app-123", }) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, string(body)) } resp.Body.Close() row, _ := e.store.GetWorkloadByID(id) if row.AppID != "app-123" { t.Fatalf("AppID = %q, want app-123", row.AppID) } } // ============================================================================= // /api/workloads/{id}/env CRUD // ============================================================================= func TestWorkloadEnv_PutListDelete(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "with-env") // PUT (plaintext) put := e.do(t, http.MethodPut, "/api/workloads/"+id+"/env", map[string]any{ "key": "DATABASE_URL", "value": "postgres://plain", "encrypted": false, }) if put.StatusCode != http.StatusOK { body, _ := io.ReadAll(put.Body) t.Fatalf("PUT status = %d (body=%s)", put.StatusCode, string(body)) } put.Body.Close() // LIST listResp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/env", nil) var rows []workloadEnvRow _ = decodeEnvelope(t, listResp, &rows) if len(rows) != 1 { t.Fatalf("expected 1 row, got %d", len(rows)) } if rows[0].Value != "postgres://plain" { t.Fatalf("plaintext value missing in list: got %q", rows[0].Value) } // DELETE delResp := e.do(t, http.MethodDelete, "/api/workloads/"+id+"/env/"+rows[0].ID, nil) if delResp.StatusCode != http.StatusOK { t.Fatalf("DELETE status = %d", delResp.StatusCode) } delResp.Body.Close() listResp2 := e.do(t, http.MethodGet, "/api/workloads/"+id+"/env", nil) var rows2 []workloadEnvRow _ = decodeEnvelope(t, listResp2, &rows2) if len(rows2) != 0 { t.Fatalf("expected 0 rows after delete, got %d", len(rows2)) } } func TestWorkloadEnv_EncryptedValueNotEchoed(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "secret-env") plain := "super-secret-value-12345" put := e.do(t, http.MethodPut, "/api/workloads/"+id+"/env", map[string]any{ "key": "API_KEY", "value": plain, "encrypted": true, }) put.Body.Close() if put.StatusCode != http.StatusOK { t.Fatalf("PUT status = %d", put.StatusCode) } listResp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/env", nil) defer listResp.Body.Close() body, _ := io.ReadAll(listResp.Body) if strings.Contains(string(body), plain) { t.Fatalf("encrypted plaintext leaked in response body: %s", string(body)) } // Cross-check: the stored ciphertext must decrypt back to the plain value. rows, _ := e.store.ListWorkloadEnv(id) if len(rows) != 1 || !rows[0].Encrypted { t.Fatalf("expected 1 encrypted row, got %+v", rows) } dec, err := crypto.Decrypt(e.encKey, rows[0].Value) if err != nil { t.Fatalf("decrypt at-rest value: %v", err) } if dec != plain { t.Fatalf("decrypted = %q, want %q", dec, plain) } } func TestWorkloadEnv_RejectsInvalidKey(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "bad-env-key") resp := e.do(t, http.MethodPut, "/api/workloads/"+id+"/env", map[string]any{ "key": "1BAD-KEY", "value": "x", }) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } } // ============================================================================= // /api/workloads/{id}/volumes CRUD // ============================================================================= func TestWorkloadVolumes_PutListDelete(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "with-vols") put := e.do(t, http.MethodPut, "/api/workloads/"+id+"/volumes", map[string]any{ "source": "/srv/data", "target": "/data", "scope": "absolute", }) if put.StatusCode != http.StatusOK { body, _ := io.ReadAll(put.Body) t.Fatalf("PUT status = %d (body=%s)", put.StatusCode, string(body)) } put.Body.Close() listResp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/volumes", nil) var rows []store.WorkloadVolume _ = decodeEnvelope(t, listResp, &rows) if len(rows) != 1 { t.Fatalf("expected 1 volume, got %d", len(rows)) } delResp := e.do(t, http.MethodDelete, "/api/workloads/"+id+"/volumes/"+rows[0].ID, nil) if delResp.StatusCode != http.StatusOK { t.Fatalf("DELETE status = %d", delResp.StatusCode) } delResp.Body.Close() rowsAfter, _ := e.store.ListWorkloadVolumes(id) if len(rowsAfter) != 0 { t.Fatalf("expected 0 volumes after delete, got %d", len(rowsAfter)) } } func TestWorkloadVolumes_RejectsTraversal(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "no-traversal") cases := []struct { name string body map[string]any }{ {"target with ..", map[string]any{"source": "/srv/data", "target": "/data/../etc", "scope": "absolute"}}, {"source with ..", map[string]any{"source": "/srv/../etc/shadow", "target": "/d", "scope": "absolute"}}, {"target not absolute", map[string]any{"source": "/srv/data", "target": "data", "scope": "absolute"}}, {"target empty", map[string]any{"source": "/srv/data", "target": "", "scope": "absolute"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { resp := e.do(t, http.MethodPut, "/api/workloads/"+id+"/volumes", tc.body) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { body, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, string(body)) } }) } } // ============================================================================= // GET /api/workloads/{id}/chain // ============================================================================= func TestGetWorkloadChain_ParentSelfChildren(t *testing.T) { e := newAPITestEnv(t) parentID := seedWorkload(t, e, "parent") childID := seedWorkloadWithParent(t, e, "child", parentID) resp := e.do(t, http.MethodGet, "/api/workloads/"+parentID+"/chain", nil) var got struct { Parent *map[string]any `json:"parent"` Self map[string]any `json:"self"` Children []map[string]any `json:"children"` } if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error: %q", errMsg) } if got.Parent != nil { t.Fatalf("parent should be nil for root, got %+v", *got.Parent) } if got.Self["id"] != parentID { t.Fatalf("self.id = %v, want %s", got.Self["id"], parentID) } if len(got.Children) != 1 || got.Children[0]["id"] != childID { t.Fatalf("children = %+v", got.Children) } } // ============================================================================= // POST /api/workloads/{id}/promote-from/{sourceID} // ============================================================================= func TestPromoteFrom_CopiesRunningTagToTarget(t *testing.T) { e := newAPITestEnv(t) sourceID := seedWorkload(t, e, "stage-prod") targetID := seedWorkload(t, e, "stage-staging") // Seed a "running" container with an image_tag on the source workload // so the promote endpoint has something to copy. if err := e.store.UpsertContainer(store.Container{ ID: sourceID + ":web", WorkloadID: sourceID, Role: "web", ImageTag: "v9.9.9", State: "running", LastSeenAt: store.Now(), }); err != nil { t.Fatalf("seed container: %v", err) } resp := e.do(t, http.MethodPost, fmt.Sprintf("/api/workloads/%s/promote-from/%s", targetID, sourceID), map[string]any{}, ) if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d (body=%s)", resp.StatusCode, string(body)) } resp.Body.Close() // Target workload's source_config.default_tag must now equal v9.9.9. row, _ := e.store.GetWorkloadByID(targetID) var cfg map[string]any if err := json.Unmarshal([]byte(row.SourceConfig), &cfg); err != nil { t.Fatalf("decode target source_config: %v", err) } if cfg["default_tag"] != "v9.9.9" { t.Fatalf("default_tag = %v, want v9.9.9", cfg["default_tag"]) } } // ============================================================================= // /api/workloads/{id}/triggers — list + inline create+bind // ============================================================================= func TestListBindingsForWorkload_EmptyByDefault(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "no-bindings") resp := e.do(t, http.MethodGet, "/api/workloads/"+id+"/triggers", nil) var rows []map[string]any _ = decodeEnvelope(t, resp, &rows) if len(rows) != 0 { t.Fatalf("expected 0 bindings, got %d", len(rows)) } } func TestBindTriggerToWorkload_InlineManualTrigger(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "inline-bind") body := map[string]any{ "binding_config": json.RawMessage(`{}`), "inline": map[string]any{ "kind": "manual", "name": "manual-trigger-for-inline", "config": json.RawMessage(`{}`), }, } resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/triggers", body) if resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d (body=%s)", resp.StatusCode, string(body)) } resp.Body.Close() // Verify a binding row exists for this workload. bindings, err := e.store.ListBindingsForWorkloadWithNames(id) if err != nil { t.Fatalf("list bindings: %v", err) } if len(bindings) != 1 { t.Fatalf("expected 1 binding, got %d", len(bindings)) } if bindings[0].TriggerKind != "manual" { t.Fatalf("expected manual trigger, got %q", bindings[0].TriggerKind) } } func TestBindTriggerToWorkload_RequiresEitherTriggerIDOrInline(t *testing.T) { e := newAPITestEnv(t) id := seedWorkload(t, e, "bind-validation") resp := e.do(t, http.MethodPost, "/api/workloads/"+id+"/triggers", map[string]any{ "binding_config": json.RawMessage(`{}`), }) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("status = %d, want 400", resp.StatusCode) } } // ============================================================================= // Standalone /api/triggers CRUD // ============================================================================= func TestCreateTrigger_HappyPath(t *testing.T) { e := newAPITestEnv(t) body := map[string]any{ "kind": "manual", "name": "standalone-manual", "config": json.RawMessage(`{}`), "webhook_enabled": false, } resp := e.do(t, http.MethodPost, "/api/triggers", body) if resp.StatusCode != http.StatusCreated { raw, _ := io.ReadAll(resp.Body) t.Fatalf("status = %d (body=%s)", resp.StatusCode, string(raw)) } var got map[string]any if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("envelope error %q", errMsg) } if got["kind"] != "manual" || got["name"] != "standalone-manual" { t.Fatalf("wrong shape: %v", got) } // Sanity: the row landed in the store. if _, err := e.store.GetTriggerByName("standalone-manual"); err != nil { t.Fatalf("trigger missing from store: %v", err) } } func TestCreateTrigger_DuplicateNameReturns409(t *testing.T) { e := newAPITestEnv(t) body := map[string]any{ "kind": "manual", "name": "dup-trigger", "config": json.RawMessage(`{}`), } if r := e.do(t, http.MethodPost, "/api/triggers", body); r.StatusCode != http.StatusCreated { r.Body.Close() t.Fatalf("first create status = %d", r.StatusCode) } r2 := e.do(t, http.MethodPost, "/api/triggers", body) defer r2.Body.Close() if r2.StatusCode != http.StatusConflict { t.Fatalf("dup status = %d, want 409", r2.StatusCode) } } func TestListTriggers_PopulatedKindFilter(t *testing.T) { e := newAPITestEnv(t) mkBody := func(kind, name string) map[string]any { cfg := json.RawMessage(`{}`) switch kind { case "registry": cfg = json.RawMessage(`{"image":"registry.example.com/o/a","tag_pattern":"*"}`) case "git": cfg = json.RawMessage(`{"repo":"o/r","mode":"push","branch":"main"}`) } return map[string]any{"kind": kind, "name": name, "config": cfg} } for _, kn := range []struct{ kind, name string }{ {"manual", "m1"}, {"manual", "m2"}, {"registry", "r1"}, {"git", "g1"}, } { if r := e.do(t, http.MethodPost, "/api/triggers", mkBody(kn.kind, kn.name)); r.StatusCode != http.StatusCreated { raw, _ := io.ReadAll(r.Body) t.Fatalf("seed %s/%s: status %d (%s)", kn.kind, kn.name, r.StatusCode, raw) } else { r.Body.Close() } } resp := e.do(t, http.MethodGet, "/api/triggers", nil) var all []map[string]any _ = decodeEnvelope(t, resp, &all) if len(all) != 4 { t.Fatalf("all triggers = %d, want 4", len(all)) } r2 := e.do(t, http.MethodGet, "/api/triggers?kind=manual", nil) var manuals []map[string]any _ = decodeEnvelope(t, r2, &manuals) if len(manuals) != 2 { t.Fatalf("manual triggers = %d, want 2", len(manuals)) } for _, row := range manuals { if row["kind"] != "manual" { t.Fatalf("kind filter leaked: %v", row) } } } func TestDeleteTrigger_RemovesRow(t *testing.T) { e := newAPITestEnv(t) createResp := e.do(t, http.MethodPost, "/api/triggers", map[string]any{ "kind": "manual", "name": "del-me", "config": json.RawMessage(`{}`), }) var created map[string]any _ = decodeEnvelope(t, createResp, &created) id, _ := created["id"].(string) if id == "" { t.Fatal("no id in create response") } r2 := e.do(t, http.MethodDelete, "/api/triggers/"+id, nil) r2.Body.Close() if r2.StatusCode != http.StatusOK { t.Fatalf("delete status = %d", r2.StatusCode) } if _, err := e.store.GetTriggerByID(id); err == nil { t.Fatal("trigger still in store after delete") } } // ============================================================================= // Auth gating // ============================================================================= func TestAdminOnlyRoutes_RejectViewerToken(t *testing.T) { e := newAPITestEnv(t) // Mint a viewer token using a fresh LocalAuth bound to the same key. la := auth.NewLocalAuth(e.encKey) tok, err := la.GenerateToken(auth.Claims{ UserID: "u-viewer", Username: "viewer", Role: "viewer", }) if err != nil { t.Fatalf("mint viewer token: %v", err) } body := pluginWorkloadRequest{ Name: "x", SourceKind: "image", SourceConfig: validImageSourceConfig(), } b, _ := json.Marshal(body) req, _ := http.NewRequest(http.MethodPost, e.srv.URL+"/api/workloads", bytes.NewReader(b)) req.Header.Set("Authorization", "Bearer "+tok.Token) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Fatalf("viewer status = %d, want 403", resp.StatusCode) } } func TestUnauthenticatedRoutes_RejectMissingToken(t *testing.T) { e := newAPITestEnv(t) req, _ := http.NewRequest(http.MethodGet, e.srv.URL+"/api/workloads", nil) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("do: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", resp.StatusCode) } } // ============================================================================= // Test-only fixtures // ============================================================================= // seedWorkload creates a minimal valid image-source workload via the // real POST /api/workloads handler and returns its ID. Going through // the handler exercises the same validation + ref_id self-reference // path that production callers hit. func seedWorkload(t *testing.T, e *apiTestEnv, name string) string { t.Helper() body := pluginWorkloadRequest{ Name: name, SourceKind: "image", SourceConfig: validImageSourceConfig(), } resp := e.do(t, http.MethodPost, "/api/workloads", body) if resp.StatusCode != http.StatusCreated { _ = decodeEnvelope(t, resp, nil) t.Fatalf("seedWorkload(%s): status = %d", name, resp.StatusCode) } var got plugin.Workload if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("seedWorkload(%s): envelope error %q", name, errMsg) } return got.ID } func seedWorkloadWithParent(t *testing.T, e *apiTestEnv, name, parentID string) string { t.Helper() body := pluginWorkloadRequest{ Name: name, ParentWorkloadID: parentID, SourceKind: "image", SourceConfig: validImageSourceConfig(), } resp := e.do(t, http.MethodPost, "/api/workloads", body) if resp.StatusCode != http.StatusCreated { _ = decodeEnvelope(t, resp, nil) t.Fatalf("seedWorkloadWithParent(%s): status = %d", name, resp.StatusCode) } var got plugin.Workload if errMsg := decodeEnvelope(t, resp, &got); errMsg != "" { t.Fatalf("seedWorkloadWithParent(%s): envelope error %q", name, errMsg) } return got.ID }