chore(workload): close the workload-first arc — apps i18n + codemap + tests
Build / build (push) Successful in 10m36s
Build / build (push) Successful in 10m36s
Closes the workload-first refactor by landing the Priority 3 polish items and the Priority 4 test gap. Net: ~2,400 lines added, ~350 lines modified across 13 files. Priority 3 — polish - apps.* i18n namespace: 276 new keys across apps.list.* (27), apps.new.* (91, sibling of existing apps.new.triggers.*), and apps.detail.* (158, sibling of existing apps.detail.bindings.*). EN+RU at 1314 keys each, perfectly in sync. /apps, /apps/new, /apps/[id] now render entirely from i18n. - New codemap docs/CODEMAPS/workload-plugin.md (238 lines): Source × Trigger contract, dispatch seam, webhook fan-out path, recipes for adding a new Source or Trigger kind. Plus docs/CODEMAPS/INDEX.md gateway. Priority 4 — tests - internal/api/workloads_test.go (new, ~30 subtests): /api/workloads CRUD + deploy + delete + env + volumes + chain + promote-from + triggers list/inline-bind + auth gating + standalone /api/triggers CRUD (create / dup-409 / kind filter / delete). Uses real POST handlers via httptest.NewServer + a fake plugin source registered under "testfakesource". - internal/deployer/dispatch_test.go (new, 11 tests): DispatchPlugin / DispatchTeardown / DispatchReconcile happy + unknown-kind + propagated-error each; PluginDeps wiring; a real 2s-bounded RWMutex deadlock probe on PluginDeps vs SetDNSProvider. - internal/workload/plugin/source/compose/compose_test.go (new, ~26 subtests): composeProjectName sanitization, writeYAML / writeYAMLIfChanged hash short-circuit, Validate happy + bad inputs, Kind / SchemaSample. Coverage delta on the workload-plugin path: - internal/api: 1.1% → 16.0% - internal/deployer: 0% → 54.1% - internal/workload/plugin/source/compose: 0% → 38.5% - Trigger plugins already at 87-95% from the trigger-split work. Production fix surfaced by the tests - store.CreateWorkload now self-references RefID = ID when caller leaves RefID empty (the typical plugin-native path). The api layer's broken backfill loop (called UpdateWorkload, which deliberately omits ref_id) is gone. Multiple sibling plugin workloads can now coexist under the UNIQUE(kind, ref_id) constraint. Review fixes addressed before commit - CRITICAL: deadlock-detect test gained a real 2s time.After (was selecting on context.Background().Done() which never fires). - HIGH: happy-path test now hard-asserts RefID = ID (was a t.Logf that would silently pass after a production fix). - HIGH: standalone /api/triggers CRUD coverage added (was bypassed by the workload-side bind flow). - HIGH: seedWorkload bypass deleted; tests now go through the real POST /api/workloads handler. - MEDIUM: withTempDir restore is a no-op (t.Setenv auto-restores); dead `old := os.Getenv(...)` capture removed. - MEDIUM: list-workloads test now asserts ID membership, not just count. Doc - WORKLOAD_REFACTOR_TODO: all three Priority 1 items, Priority 3 polish, and Priority 4 tests marked DONE. The workload-first arc is closed.
This commit is contained in:
@@ -82,10 +82,9 @@ func (s *Server) createPluginWorkload(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusBadRequest, "encode workload: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Plugin-native rows are flagged with kind="plugin"; ref_id is left
|
||||
// empty by the caller and filled with the generated ID below so the
|
||||
// UNIQUE(kind, ref_id) index can hold many plugin workloads (each
|
||||
// pair is the row's own ID, which is itself unique).
|
||||
// Plugin-native rows are flagged with kind="plugin"; ref_id is
|
||||
// self-referenced to the row's own ID inside CreateWorkload so the
|
||||
// UNIQUE(kind, ref_id) index can hold many sibling plugin workloads.
|
||||
sw.Kind = "plugin"
|
||||
created, err := s.store.CreateWorkload(sw)
|
||||
if err != nil {
|
||||
@@ -93,15 +92,6 @@ func (s *Server) createPluginWorkload(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusInternalServerError, "create workload")
|
||||
return
|
||||
}
|
||||
if created.RefID == "" {
|
||||
// Self-reference so (kind, ref_id) stays unique. Done as a follow-up
|
||||
// update — CreateWorkload generates the UUID itself, so the value is
|
||||
// only known after insert.
|
||||
created.RefID = created.ID
|
||||
if err := s.store.UpdateWorkload(created); err != nil {
|
||||
slog.Warn("backfill plugin workload ref_id", "id", created.ID, "error", err)
|
||||
}
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, toPluginWorkload(created))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,995 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package deployer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// fakeSource is a stub Source implementation registered exactly once
|
||||
// (kind="dispatchertest") so each dispatch test can assert exactly which
|
||||
// lifecycle method ran. Counters and the configured error are atomic /
|
||||
// mutex-guarded because a future parallel run should not flake.
|
||||
type fakeSource struct {
|
||||
kind string
|
||||
|
||||
mu sync.Mutex
|
||||
deployErr error
|
||||
teardownErr error
|
||||
reconcileErr error
|
||||
|
||||
deployCount atomic.Int32
|
||||
teardownCount atomic.Int32
|
||||
reconcileCount atomic.Int32
|
||||
|
||||
lastIntent plugin.DeploymentIntent
|
||||
lastDeps plugin.Deps
|
||||
}
|
||||
|
||||
func (f *fakeSource) Kind() string { return f.kind }
|
||||
func (f *fakeSource) SchemaSample() any { return struct{}{} }
|
||||
func (f *fakeSource) Validate(json.RawMessage) error { return nil }
|
||||
|
||||
func (f *fakeSource) Deploy(_ context.Context, deps plugin.Deps, _ plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
f.deployCount.Add(1)
|
||||
f.mu.Lock()
|
||||
f.lastIntent = intent
|
||||
f.lastDeps = deps
|
||||
err := f.deployErr
|
||||
f.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeSource) Teardown(_ context.Context, deps plugin.Deps, _ plugin.Workload) error {
|
||||
f.teardownCount.Add(1)
|
||||
f.mu.Lock()
|
||||
f.lastDeps = deps
|
||||
err := f.teardownErr
|
||||
f.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeSource) Reconcile(_ context.Context, deps plugin.Deps, _ plugin.Workload) error {
|
||||
f.reconcileCount.Add(1)
|
||||
f.mu.Lock()
|
||||
f.lastDeps = deps
|
||||
err := f.reconcileErr
|
||||
f.mu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *fakeSource) setDeployErr(err error) { f.mu.Lock(); f.deployErr = err; f.mu.Unlock() }
|
||||
func (f *fakeSource) setTeardownErr(err error) { f.mu.Lock(); f.teardownErr = err; f.mu.Unlock() }
|
||||
func (f *fakeSource) setReconcileErr(err error) { f.mu.Lock(); f.reconcileErr = err; f.mu.Unlock() }
|
||||
|
||||
// dispatchTestSource is the singleton fake registered into the plugin
|
||||
// registry. Registration happens exactly once — subsequent calls would
|
||||
// panic (RegisterSource panics on duplicate kind).
|
||||
var dispatchTestSource = &fakeSource{kind: "dispatchertest"}
|
||||
|
||||
func init() {
|
||||
plugin.RegisterSource(dispatchTestSource)
|
||||
}
|
||||
|
||||
// resetFake clears counters + queued errors between tests. The Source
|
||||
// instance is shared (the registry can't be cleared per-test) so reset
|
||||
// is the seam.
|
||||
func resetFake(t *testing.T) {
|
||||
t.Helper()
|
||||
dispatchTestSource.mu.Lock()
|
||||
dispatchTestSource.deployErr = nil
|
||||
dispatchTestSource.teardownErr = nil
|
||||
dispatchTestSource.reconcileErr = nil
|
||||
dispatchTestSource.lastIntent = plugin.DeploymentIntent{}
|
||||
dispatchTestSource.lastDeps = plugin.Deps{}
|
||||
dispatchTestSource.mu.Unlock()
|
||||
dispatchTestSource.deployCount.Store(0)
|
||||
dispatchTestSource.teardownCount.Store(0)
|
||||
dispatchTestSource.reconcileCount.Store(0)
|
||||
}
|
||||
|
||||
func newTestDeployer(t *testing.T) *Deployer {
|
||||
t.Helper()
|
||||
st, err := store.New(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("create store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
// All other deps are nil — the fake source ignores them. The dispatch
|
||||
// surface itself does not dereference them.
|
||||
return New(nil, nil, st, nil, nil, nil, [32]byte{})
|
||||
}
|
||||
|
||||
func sampleWorkload() plugin.Workload {
|
||||
return plugin.Workload{
|
||||
ID: "wid-dispatch",
|
||||
Name: "wkl",
|
||||
SourceKind: "dispatchertest",
|
||||
SourceConfig: json.RawMessage(`{}`),
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DispatchPlugin ---------------------------------------------------------
|
||||
|
||||
func TestDispatchPlugin_HappyPath_CallsDeployOnce(t *testing.T) {
|
||||
resetFake(t)
|
||||
d := newTestDeployer(t)
|
||||
|
||||
intent := plugin.DeploymentIntent{Reason: "manual", TriggeredBy: "alice"}
|
||||
if err := d.DispatchPlugin(context.Background(), sampleWorkload(), intent); err != nil {
|
||||
t.Fatalf("DispatchPlugin: %v", err)
|
||||
}
|
||||
if got := dispatchTestSource.deployCount.Load(); got != 1 {
|
||||
t.Fatalf("Deploy called %d times, want 1", got)
|
||||
}
|
||||
if dispatchTestSource.lastIntent.Reason != "manual" {
|
||||
t.Fatalf("intent.Reason = %q, want manual", dispatchTestSource.lastIntent.Reason)
|
||||
}
|
||||
if dispatchTestSource.lastIntent.TriggeredBy != "alice" {
|
||||
t.Fatalf("intent.TriggeredBy = %q, want alice", dispatchTestSource.lastIntent.TriggeredBy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchPlugin_UnknownKind_ReturnsRegistryError(t *testing.T) {
|
||||
resetFake(t)
|
||||
d := newTestDeployer(t)
|
||||
|
||||
w := sampleWorkload()
|
||||
w.SourceKind = "no-such-kind"
|
||||
err := d.DispatchPlugin(context.Background(), w, plugin.DeploymentIntent{})
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for unknown kind, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no source registered") {
|
||||
t.Fatalf("error = %q, want substring 'no source registered'", err.Error())
|
||||
}
|
||||
if got := dispatchTestSource.deployCount.Load(); got != 0 {
|
||||
t.Fatalf("Deploy must not be called for unknown kind, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchPlugin_PropagatesSourceError(t *testing.T) {
|
||||
resetFake(t)
|
||||
d := newTestDeployer(t)
|
||||
|
||||
want := errors.New("boom")
|
||||
dispatchTestSource.setDeployErr(want)
|
||||
|
||||
err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{})
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("expected wrapped error to match %v, got %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DispatchTeardown -------------------------------------------------------
|
||||
|
||||
func TestDispatchTeardown_HappyPath_CallsTeardownOnce(t *testing.T) {
|
||||
resetFake(t)
|
||||
d := newTestDeployer(t)
|
||||
|
||||
if err := d.DispatchTeardown(context.Background(), sampleWorkload()); err != nil {
|
||||
t.Fatalf("DispatchTeardown: %v", err)
|
||||
}
|
||||
if got := dispatchTestSource.teardownCount.Load(); got != 1 {
|
||||
t.Fatalf("Teardown called %d times, want 1", got)
|
||||
}
|
||||
if got := dispatchTestSource.deployCount.Load(); got != 0 {
|
||||
t.Fatalf("Teardown must not call Deploy, got %d Deploy calls", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchTeardown_UnknownKind_ReturnsRegistryError(t *testing.T) {
|
||||
resetFake(t)
|
||||
d := newTestDeployer(t)
|
||||
|
||||
w := sampleWorkload()
|
||||
w.SourceKind = "no-such-kind"
|
||||
err := d.DispatchTeardown(context.Background(), w)
|
||||
if err == nil || !strings.Contains(err.Error(), "no source registered") {
|
||||
t.Fatalf("expected unknown-kind error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchTeardown_PropagatesSourceError(t *testing.T) {
|
||||
resetFake(t)
|
||||
d := newTestDeployer(t)
|
||||
|
||||
want := errors.New("teardown failed")
|
||||
dispatchTestSource.setTeardownErr(want)
|
||||
|
||||
err := d.DispatchTeardown(context.Background(), sampleWorkload())
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("expected wrapped error to match %v, got %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- DispatchReconcile ------------------------------------------------------
|
||||
|
||||
func TestDispatchReconcile_HappyPath_CallsReconcileOnce(t *testing.T) {
|
||||
resetFake(t)
|
||||
d := newTestDeployer(t)
|
||||
|
||||
if err := d.DispatchReconcile(context.Background(), sampleWorkload()); err != nil {
|
||||
t.Fatalf("DispatchReconcile: %v", err)
|
||||
}
|
||||
if got := dispatchTestSource.reconcileCount.Load(); got != 1 {
|
||||
t.Fatalf("Reconcile called %d times, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReconcile_UnknownKind_ReturnsRegistryError(t *testing.T) {
|
||||
resetFake(t)
|
||||
d := newTestDeployer(t)
|
||||
|
||||
w := sampleWorkload()
|
||||
w.SourceKind = "no-such-kind"
|
||||
err := d.DispatchReconcile(context.Background(), w)
|
||||
if err == nil || !strings.Contains(err.Error(), "no source registered") {
|
||||
t.Fatalf("expected unknown-kind error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchReconcile_PropagatesSourceError(t *testing.T) {
|
||||
resetFake(t)
|
||||
d := newTestDeployer(t)
|
||||
|
||||
want := errors.New("reconcile failed")
|
||||
dispatchTestSource.setReconcileErr(want)
|
||||
|
||||
err := d.DispatchReconcile(context.Background(), sampleWorkload())
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("expected wrapped error to match %v, got %v", want, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- PluginDeps -------------------------------------------------------------
|
||||
|
||||
func TestPluginDeps_PassesStoreAndEncKey(t *testing.T) {
|
||||
resetFake(t)
|
||||
d := newTestDeployer(t)
|
||||
|
||||
if err := d.DispatchPlugin(context.Background(), sampleWorkload(), plugin.DeploymentIntent{}); err != nil {
|
||||
t.Fatalf("dispatch: %v", err)
|
||||
}
|
||||
got := dispatchTestSource.lastDeps
|
||||
if got.Store != d.store {
|
||||
t.Fatalf("Deps.Store mismatch: got %p want %p", got.Store, d.store)
|
||||
}
|
||||
// EncKey is a value type — compare bytes.
|
||||
if got.EncKey != d.encKey {
|
||||
t.Fatalf("Deps.EncKey not propagated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginDeps_DNSReadUnderRWMutex_NoDeadlockOnHotSwap(t *testing.T) {
|
||||
// PluginDeps takes dnsMu.RLock; SetDNSProvider takes dnsMu.Lock. A bug
|
||||
// where the read code path also took the write lock would deadlock
|
||||
// when a concurrent SetDNSProvider runs. Run both in parallel goroutines
|
||||
// and assert both finish.
|
||||
d := newTestDeployer(t)
|
||||
|
||||
const N = 50
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2 * N)
|
||||
for i := 0; i < N; i++ {
|
||||
go func() { defer wg.Done(); _ = d.PluginDeps() }()
|
||||
go func() { defer wg.Done(); d.SetDNSProvider(nil) }()
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() { wg.Wait(); close(done) }()
|
||||
|
||||
// Real timeout: a deadlock here would hang `go test` for the entire
|
||||
// package timeout (default 10 min) and report no useful diagnostic.
|
||||
// Bound at 2s so a regression fails this test specifically.
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("deadlock: PluginDeps/SetDNSProvider did not finish within 2s")
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,19 @@ func scanWorkload(scanner interface{ Scan(...any) error }) (Workload, error) {
|
||||
return w, err
|
||||
}
|
||||
|
||||
// CreateWorkload inserts a new workload row. The (Kind, RefID) pair must be
|
||||
// unique; the caller is responsible for matching this to a project/stack/site.
|
||||
// CreateWorkload inserts a new workload row. The (Kind, RefID) pair
|
||||
// must be unique; for plugin-native rows (Kind="plugin") the caller
|
||||
// typically leaves RefID empty and we self-reference it to the row's
|
||||
// own ID so the UNIQUE(kind, ref_id) constraint holds for many sibling
|
||||
// plugin workloads. Legacy bridge code that wired ref_id to a
|
||||
// project/stack/site row was deleted in the hard cutover.
|
||||
func (s *Store) CreateWorkload(w Workload) (Workload, error) {
|
||||
if w.ID == "" {
|
||||
w.ID = uuid.New().String()
|
||||
}
|
||||
if w.RefID == "" {
|
||||
w.RefID = w.ID
|
||||
}
|
||||
w.CreatedAt = Now()
|
||||
w.UpdatedAt = w.CreatedAt
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
func TestComposeProjectName_GivenExplicitValue_PassesThroughVerbatim(t *testing.T) {
|
||||
got := composeProjectName("my-explicit-project", plugin.Workload{
|
||||
ID: "abcd1234-5678-1234-abcd-deadbeef0000",
|
||||
Name: "WhAtEvEr Name!",
|
||||
})
|
||||
if got != "my-explicit-project" {
|
||||
t.Fatalf("explicit value should win verbatim, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeProjectName_GivenNoExplicit_DerivesStableSlug(t *testing.T) {
|
||||
w := plugin.Workload{
|
||||
ID: "abcd1234-5678-1234-abcd-deadbeef0000",
|
||||
Name: "My App",
|
||||
}
|
||||
got1 := composeProjectName("", w)
|
||||
got2 := composeProjectName("", w)
|
||||
|
||||
if got1 != got2 {
|
||||
t.Fatalf("derived name must be stable across calls: %q != %q", got1, got2)
|
||||
}
|
||||
if !strings.HasPrefix(got1, "tf-") {
|
||||
t.Fatalf("expected tf- prefix, got %q", got1)
|
||||
}
|
||||
if !strings.HasSuffix(got1, "-abcd1234") {
|
||||
t.Fatalf("expected workload ID short prefix suffix, got %q", got1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeProjectName_SanitizesSpecialChars(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
// We assert with substring checks so the test does not need to
|
||||
// re-implement the full normalization logic.
|
||||
mustContain []string
|
||||
mustNotContain []string
|
||||
}{
|
||||
{
|
||||
name: "spaces become dashes",
|
||||
in: "My App",
|
||||
mustContain: []string{"my-app"},
|
||||
mustNotContain: []string{" "},
|
||||
},
|
||||
{
|
||||
name: "uppercase folded to lowercase",
|
||||
in: "UPPERCASE",
|
||||
mustContain: []string{"uppercase"},
|
||||
mustNotContain: []string{"UPPERCASE"},
|
||||
},
|
||||
{
|
||||
name: "non-alphanum stripped to dash",
|
||||
in: "weird!@#name",
|
||||
mustContain: []string{"weird"},
|
||||
mustNotContain: []string{"!", "@", "#"},
|
||||
},
|
||||
{
|
||||
name: "leading-and-trailing dashes stripped",
|
||||
in: "---name---",
|
||||
mustContain: []string{"name"},
|
||||
mustNotContain: []string{"--name", "name--"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := composeProjectName("", plugin.Workload{
|
||||
ID: "abcd1234-5678-1234-abcd-deadbeef0000",
|
||||
Name: tc.in,
|
||||
})
|
||||
for _, s := range tc.mustContain {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("expected %q to contain %q", got, s)
|
||||
}
|
||||
}
|
||||
for _, s := range tc.mustNotContain {
|
||||
if strings.Contains(got, s) {
|
||||
t.Errorf("expected %q to NOT contain %q", got, s)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeProjectName_EmptyAfterSanitize_FallsBackToWkl(t *testing.T) {
|
||||
got := composeProjectName("", plugin.Workload{
|
||||
ID: "abcd1234-rest",
|
||||
Name: "!!!@@@###",
|
||||
})
|
||||
// All chars get stripped, falls back to "wkl" + ID short prefix.
|
||||
if !strings.Contains(got, "wkl") {
|
||||
t.Fatalf("expected wkl fallback in %q", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "-abcd1234") {
|
||||
t.Fatalf("expected ID short prefix suffix in %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeProjectName_ShortIDDoesNotPanic(t *testing.T) {
|
||||
// IDs shorter than 8 chars must not panic — they are taken verbatim.
|
||||
got := composeProjectName("", plugin.Workload{
|
||||
ID: "ab",
|
||||
Name: "app",
|
||||
})
|
||||
if !strings.HasSuffix(got, "-ab") {
|
||||
t.Fatalf("short ID handling regressed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// withTempDir tries to point os.TempDir() at a per-test scratch
|
||||
// directory. Note: Go's os.TempDir reads TMPDIR/TMP/TEMP on every call
|
||||
// (no process-init cache), but isolation across tests rests primarily
|
||||
// on each test passing a distinct workload-id-derived subdir to
|
||||
// writeYAML — the env redirect just keeps the directory tree under
|
||||
// t.TempDir() so the test runner cleans it up at the end. t.Setenv
|
||||
// restores prior values automatically.
|
||||
func withTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
t.Setenv("TMPDIR", dir)
|
||||
t.Setenv("TMP", dir)
|
||||
t.Setenv("TEMP", dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestWriteYAML_CreatesFileWithCorrectContents(t *testing.T) {
|
||||
withTempDir(t)
|
||||
wid := "wid-write-yaml"
|
||||
path, err := writeYAML(wid, "services:\n web:\n image: nginx:alpine\n")
|
||||
if err != nil {
|
||||
t.Fatalf("writeYAML: %v", err)
|
||||
}
|
||||
if filepath.Base(path) != "compose.yml" {
|
||||
t.Fatalf("expected compose.yml, got %q", filepath.Base(path))
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), "nginx:alpine") {
|
||||
t.Fatalf("yaml content missing expected line, got %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteYAMLIfChanged_NoRewriteWhenIdentical(t *testing.T) {
|
||||
withTempDir(t)
|
||||
wid := "wid-no-rewrite"
|
||||
yaml := "services:\n web:\n image: nginx:alpine\n"
|
||||
|
||||
path, err := writeYAML(wid, yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("seed writeYAML: %v", err)
|
||||
}
|
||||
st1, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
|
||||
// Sleep is unreliable; instead, modify the file then call
|
||||
// writeYAMLIfChanged with identical content and assert it did NOT
|
||||
// touch the file by checking ModTime is unchanged.
|
||||
origMod := st1.ModTime()
|
||||
|
||||
path2, err := writeYAMLIfChanged(wid, yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("writeYAMLIfChanged: %v", err)
|
||||
}
|
||||
if path2 != path {
|
||||
t.Fatalf("path mismatch: %q vs %q", path, path2)
|
||||
}
|
||||
st2, err := os.Stat(path2)
|
||||
if err != nil {
|
||||
t.Fatalf("stat after: %v", err)
|
||||
}
|
||||
if !st2.ModTime().Equal(origMod) {
|
||||
t.Fatalf("file was rewritten despite identical contents (mtime changed: %v -> %v)", origMod, st2.ModTime())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteYAMLIfChanged_RewritesWhenDifferent(t *testing.T) {
|
||||
withTempDir(t)
|
||||
wid := "wid-rewrite"
|
||||
yaml1 := "services:\n web:\n image: nginx:alpine\n"
|
||||
yaml2 := "services:\n web:\n image: nginx:1.25\n"
|
||||
|
||||
if _, err := writeYAML(wid, yaml1); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
path, err := writeYAMLIfChanged(wid, yaml2)
|
||||
if err != nil {
|
||||
t.Fatalf("writeYAMLIfChanged: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(body), "nginx:1.25") {
|
||||
t.Fatalf("yaml not updated, got %q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteYAMLIfChanged_MissingFile_Writes(t *testing.T) {
|
||||
withTempDir(t)
|
||||
wid := "wid-missing"
|
||||
yaml := "services:\n web:\n image: alpine\n"
|
||||
|
||||
path, err := writeYAMLIfChanged(wid, yaml)
|
||||
if err != nil {
|
||||
t.Fatalf("writeYAMLIfChanged: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected file to be created, stat err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
n int
|
||||
want string
|
||||
}{
|
||||
{"short", 100, "short"},
|
||||
{"exact", 5, "exact"},
|
||||
{"longerstring", 4, "long...(truncated)"},
|
||||
{"", 5, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := truncate(tc.in, tc.n); got != tc.want {
|
||||
t.Fatalf("truncate(%q,%d): got %q want %q", tc.in, tc.n, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_GivenValidYAML_Passes(t *testing.T) {
|
||||
src := &source{}
|
||||
cfg := Config{
|
||||
ComposeYAML: "services:\n web:\n image: nginx:alpine\n ports:\n - \"80\"\n",
|
||||
}
|
||||
body, _ := json.Marshal(cfg)
|
||||
if err := src.Validate(body); err != nil {
|
||||
t.Fatalf("expected pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate_RejectsKnownBadInputs(t *testing.T) {
|
||||
src := &source{}
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
wantSub string
|
||||
}{
|
||||
{"empty config", "", "config is required"},
|
||||
{"invalid json", `{not json`, "invalid json"},
|
||||
{"missing yaml", `{"compose_yaml":""}`, "compose_yaml is required"},
|
||||
{"yaml whitespace only", `{"compose_yaml":" \n "}`, "compose_yaml is required"},
|
||||
{"unparseable yaml", `{"compose_yaml":":\n bad\n indent"}`, "parse yaml"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := src.Validate([]byte(tc.body))
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantSub) {
|
||||
t.Fatalf("error %q missing substring %q", err.Error(), tc.wantSub)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindAndSchemaSample(t *testing.T) {
|
||||
src := &source{}
|
||||
if src.Kind() != "compose" {
|
||||
t.Fatalf("Kind: %q", src.Kind())
|
||||
}
|
||||
sample := src.SchemaSample()
|
||||
cfg, ok := sample.(Config)
|
||||
if !ok {
|
||||
t.Fatalf("SchemaSample is not Config: %T", sample)
|
||||
}
|
||||
if cfg.ComposeYAML == "" {
|
||||
t.Fatalf("SchemaSample missing ComposeYAML")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user