Files
tiny-forge/internal/api/workloads_test.go
T
alexei.dolgolyov 1c47030854 feat(volsnap): volume snapshot restore (backlog #6)
Restore a captured volume snapshot onto an image workload's live host-bind
data volumes, then redeploy — the most destructive workload action, built to
the adversarially-reviewed design (C1–C6) with all data-loss guards.

- Engine.Restore (engine-owned): all-or-nothing pre-flight re-resolution from
  the workload's CURRENT config (never the tamperable manifest), per-filesystem
  disk pre-check, per-workload lock, container quiesce, extract-to-tmp, durable
  pre-restore snapshot, write-ahead journal, atomic rename swap, redeploy, and
  crash-recovery sweep (RecoverInterruptedRestores) wired before serving.
- internal/keyedmutex: shared per-key lock; deployer now serializes every
  deploy entrypoint per workload via DispatchPlugin (+ LockWorkload/RedeployLocked
  for the restore re-dispatch, no deadlock).
- Untrusted-archive extractor: zip-slip containment, type allow-list (reg/dir
  only), decompression-bomb cap, manifest-index bounds.
- POST /api/workloads/{id}/snapshots/{sid}/restore: admin, X-Confirm-Restore
  header (CSRF), per-workload single-flight (409).
- WebUI: Restore button + danger ConfirmDialog + busy state + i18n (en/ru).

Scope: image-source only; scopes absolute/stage/project (driven off the same
supportedScopes constant capture uses).

Plan-reviewed before coding; per-phase go/security/ts reviews; final review
READY TO MERGE. Security review caught + fixed a CRITICAL manifest-Source path
traversal (re-derive target from current config + base containment).

Plan: plans/volume-snapshot-restore/
2026-06-22 17:23:52 +03:00

998 lines
32 KiB
Go

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/volsnap"
"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
snapEngine *volsnap.Engine // set by newSnapshotEnv; nil otherwise
}
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
}