-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtriggers_test.go
More file actions
387 lines (348 loc) · 12.3 KB
/
Copy pathtriggers_test.go
File metadata and controls
387 lines (348 loc) · 12.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
package workflow
import (
"context"
"database/sql"
"testing"
"github.com/dvflw/mantle/internal/auth"
)
// countTriggers returns the number of workflow_triggers rows for a workflow,
// optionally filtering to enabled rows only.
func countTriggers(t *testing.T, database *sql.DB, name string, enabledOnly bool) int {
t.Helper()
q := `SELECT COUNT(*) FROM workflow_triggers WHERE workflow_name = $1`
if enabledOnly {
q += ` AND enabled = true`
}
var n int
if err := database.QueryRowContext(context.Background(), q, name).Scan(&n); err != nil {
t.Fatalf("counting triggers: %v", err)
}
return n
}
var triggeredWorkflowYAML = []byte(`name: triggered-wf
description: workflow with all trigger types
triggers:
- type: cron
schedule: "*/5 * * * *"
- type: webhook
path: /incoming
secret: shh
- type: email
mailbox: inbox-cred
folder: INBOX
filter: unseen
poll_interval: 30s
steps:
- name: s
action: http/request
params:
method: GET
url: "https://example.com"
`)
// TestSave_RegistersTriggers is the regression test for the bug where triggers
// declared in YAML were parsed but never written to workflow_triggers, so they
// never fired. Applying a workflow must populate the table the schedulers read.
func TestSave_RegistersTriggers(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
result, err := ParseBytes(triggeredWorkflowYAML)
if err != nil {
t.Fatalf("ParseBytes: %v", err)
}
version, err := Save(ctx, database, result, triggeredWorkflowYAML)
if err != nil {
t.Fatalf("Save: %v", err)
}
if version != 1 {
t.Fatalf("version = %d, want 1", version)
}
if got := countTriggers(t, database, "triggered-wf", false); got != 3 {
t.Fatalf("registered triggers = %d, want 3", got)
}
// The cron trigger row must carry the schedule and point at the version.
var (
schedule string
ver int
)
if err := database.QueryRowContext(ctx,
`SELECT COALESCE(schedule, ''), workflow_version FROM workflow_triggers
WHERE workflow_name = 'triggered-wf' AND type = 'cron'`,
).Scan(&schedule, &ver); err != nil {
t.Fatalf("querying cron trigger: %v", err)
}
if schedule != "*/5 * * * *" {
t.Errorf("cron schedule = %q, want %q", schedule, "*/5 * * * *")
}
if ver != 1 {
t.Errorf("cron trigger version = %d, want 1", ver)
}
// The email trigger row must carry its email-specific fields.
var mailbox, folder, filter, poll string
if err := database.QueryRowContext(ctx,
`SELECT COALESCE(mailbox,''), COALESCE(folder,''), COALESCE(filter,''), COALESCE(poll_interval,'')
FROM workflow_triggers WHERE workflow_name = 'triggered-wf' AND type = 'email'`,
).Scan(&mailbox, &folder, &filter, &poll); err != nil {
t.Fatalf("querying email trigger: %v", err)
}
if mailbox != "inbox-cred" || folder != "INBOX" || filter != "unseen" || poll != "30s" {
t.Errorf("email fields = (%q,%q,%q,%q), want (inbox-cred,INBOX,unseen,30s)",
mailbox, folder, filter, poll)
}
// The webhook trigger row must carry its path and secret.
var path, secret string
if err := database.QueryRowContext(ctx,
`SELECT COALESCE(path,''), COALESCE(secret,'') FROM workflow_triggers
WHERE workflow_name = 'triggered-wf' AND type = 'webhook'`,
).Scan(&path, &secret); err != nil {
t.Fatalf("querying webhook trigger: %v", err)
}
if path != "/incoming" || secret != "shh" {
t.Errorf("webhook fields = (%q,%q), want (/incoming,shh)", path, secret)
}
}
// triggerIDs returns the sorted IDs of a workflow's trigger rows.
func triggerIDs(t *testing.T, database *sql.DB, name string) []string {
t.Helper()
rows, err := database.QueryContext(context.Background(),
`SELECT id::text FROM workflow_triggers WHERE workflow_name = $1 ORDER BY id`, name)
if err != nil {
t.Fatalf("querying trigger ids: %v", err)
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
t.Fatalf("scanning id: %v", err)
}
ids = append(ids, id)
}
return ids
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// TestSave_BackfillsTriggersOnUnchangedApply is the regression test for the
// upgrade path: a workflow whose definition already exists but has no
// workflow_triggers rows (applied before trigger registration existed) must
// get its triggers backfilled when identical content is re-applied — without
// churning rows on subsequent no-op applies.
func TestSave_BackfillsTriggersOnUnchangedApply(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
result, _ := ParseBytes(triggeredWorkflowYAML)
if _, err := Save(ctx, database, result, triggeredWorkflowYAML); err != nil {
t.Fatalf("initial Save: %v", err)
}
// Simulate a pre-fix workflow: definition present, trigger rows absent.
if _, err := database.ExecContext(ctx,
`DELETE FROM workflow_triggers WHERE workflow_name = 'triggered-wf'`); err != nil {
t.Fatalf("clearing triggers: %v", err)
}
if got := countTriggers(t, database, "triggered-wf", false); got != 0 {
t.Fatalf("precondition: triggers = %d, want 0", got)
}
// Re-applying identical content must backfill the triggers (version stays 0).
version, err := Save(ctx, database, result, triggeredWorkflowYAML)
if err != nil {
t.Fatalf("backfill Save: %v", err)
}
if version != 0 {
t.Errorf("version = %d, want 0 (unchanged content)", version)
}
if got := countTriggers(t, database, "triggered-wf", true); got != 3 {
t.Fatalf("backfilled enabled triggers = %d, want 3", got)
}
// A further identical apply must be a true no-op: rows already in sync,
// so their IDs must not churn (the email poller keys off trigger IDs).
ids1 := triggerIDs(t, database, "triggered-wf")
if _, err := Save(ctx, database, result, triggeredWorkflowYAML); err != nil {
t.Fatalf("no-op Save: %v", err)
}
ids2 := triggerIDs(t, database, "triggered-wf")
if !equalStrings(ids1, ids2) {
t.Errorf("triggers churned on no-op apply: %v -> %v", ids1, ids2)
}
}
// TestSave_CronTriggersUniquePerTeam verifies that two teams can each register
// a workflow with the same name and cron schedule. Before the uniqueness index
// was team-scoped, the second team's apply failed with a unique-constraint
// violation on (workflow_name, schedule).
func TestSave_CronTriggersUniquePerTeam(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
// A second team — both workflow_definitions.team_id and
// workflow_triggers.team_id are foreign keys to teams(id).
teamB := "00000000-0000-0000-0000-0000000000b2"
if _, err := database.ExecContext(ctx,
`INSERT INTO teams (id, name) VALUES ($1, 'team-b')`, teamB); err != nil {
t.Fatalf("creating team-b: %v", err)
}
yaml := []byte(`name: shared-name
triggers:
- type: cron
schedule: "*/5 * * * *"
steps:
- name: s
action: http/request
params:
method: GET
url: "https://example.com"
`)
result, err := ParseBytes(yaml)
if err != nil {
t.Fatalf("ParseBytes: %v", err)
}
// Default team.
if _, err := Save(ctx, database, result, yaml); err != nil {
t.Fatalf("Save (default team): %v", err)
}
// team-b — same workflow name and schedule.
ctxB := auth.WithUser(ctx, &auth.User{TeamID: teamB})
if _, err := Save(ctxB, database, result, yaml); err != nil {
t.Fatalf("Save (team-b): %v", err)
}
// Each team owns exactly one cron trigger for the workflow.
for _, tid := range []string{auth.DefaultTeamID, teamB} {
var n int
if err := database.QueryRowContext(ctx,
`SELECT COUNT(*) FROM workflow_triggers
WHERE workflow_name = 'shared-name' AND type = 'cron' AND team_id = $1`, tid,
).Scan(&n); err != nil {
t.Fatalf("counting triggers for %s: %v", tid, err)
}
if n != 1 {
t.Errorf("team %s cron triggers = %d, want 1", tid, n)
}
}
}
// TestSave_BackfillPreservesDisabledState verifies that backfilling triggers
// for a currently-disabled workflow inserts them disabled, so a pruned
// workflow does not start firing on a re-apply.
func TestSave_BackfillPreservesDisabledState(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
result, _ := ParseBytes(triggeredWorkflowYAML)
if _, err := Save(ctx, database, result, triggeredWorkflowYAML); err != nil {
t.Fatalf("initial Save: %v", err)
}
if err := Disable(ctx, database, "triggered-wf"); err != nil {
t.Fatalf("Disable: %v", err)
}
// Drop the rows to force a backfill on the next apply.
if _, err := database.ExecContext(ctx,
`DELETE FROM workflow_triggers WHERE workflow_name = 'triggered-wf'`); err != nil {
t.Fatalf("clearing triggers: %v", err)
}
if _, err := Save(ctx, database, result, triggeredWorkflowYAML); err != nil {
t.Fatalf("backfill Save: %v", err)
}
if got := countTriggers(t, database, "triggered-wf", false); got != 3 {
t.Errorf("backfilled rows = %d, want 3", got)
}
if got := countTriggers(t, database, "triggered-wf", true); got != 0 {
t.Errorf("enabled backfilled rows = %d, want 0 (workflow is disabled)", got)
}
}
// TestSave_ReplacesTriggersOnNewVersion verifies a new version supersedes the
// prior triggers rather than accumulating duplicates.
func TestSave_ReplacesTriggersOnNewVersion(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
result, _ := ParseBytes(triggeredWorkflowYAML)
if _, err := Save(ctx, database, result, triggeredWorkflowYAML); err != nil {
t.Fatalf("first Save: %v", err)
}
// New version with a single cron trigger on a different schedule.
updated := []byte(`name: triggered-wf
description: now with one trigger
triggers:
- type: cron
schedule: "0 * * * *"
steps:
- name: s
action: http/request
params:
method: GET
url: "https://example.com"
`)
result2, err := ParseBytes(updated)
if err != nil {
t.Fatalf("ParseBytes updated: %v", err)
}
version, err := Save(ctx, database, result2, updated)
if err != nil {
t.Fatalf("second Save: %v", err)
}
if version != 2 {
t.Fatalf("version = %d, want 2", version)
}
if got := countTriggers(t, database, "triggered-wf", false); got != 1 {
t.Fatalf("triggers after new version = %d, want 1", got)
}
var schedule string
var ver int
if err := database.QueryRowContext(ctx,
`SELECT COALESCE(schedule,''), workflow_version FROM workflow_triggers WHERE workflow_name = 'triggered-wf'`,
).Scan(&schedule, &ver); err != nil {
t.Fatalf("querying trigger: %v", err)
}
if schedule != "0 * * * *" {
t.Errorf("schedule = %q, want %q", schedule, "0 * * * *")
}
if ver != 2 {
t.Errorf("trigger version = %d, want 2", ver)
}
}
// TestSave_NoTriggersLeavesTableEmpty verifies a workflow without triggers
// registers none (and doesn't error on the empty set).
func TestSave_NoTriggersLeavesTableEmpty(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
result, _ := ParseBytes(testWorkflowYAML) // defined in store_test.go, has no triggers
if _, err := Save(ctx, database, result, testWorkflowYAML); err != nil {
t.Fatalf("Save: %v", err)
}
if got := countTriggers(t, database, "test-workflow", false); got != 0 {
t.Errorf("triggers = %d, want 0", got)
}
}
// TestDisableReenable_TogglesTriggerEnabled verifies that disabling a workflow
// (e.g. a GitOps prune) stops its triggers from firing, and re-enabling
// restores them — including the case where the definition is unchanged so Save
// would not re-register them.
func TestDisableReenable_TogglesTriggerEnabled(t *testing.T) {
database := setupTestDB(t)
ctx := context.Background()
result, _ := ParseBytes(triggeredWorkflowYAML)
if _, err := Save(ctx, database, result, triggeredWorkflowYAML); err != nil {
t.Fatalf("Save: %v", err)
}
if got := countTriggers(t, database, "triggered-wf", true); got != 3 {
t.Fatalf("enabled triggers = %d, want 3", got)
}
if err := Disable(ctx, database, "triggered-wf"); err != nil {
t.Fatalf("Disable: %v", err)
}
if got := countTriggers(t, database, "triggered-wf", true); got != 0 {
t.Errorf("enabled triggers after Disable = %d, want 0", got)
}
// Rows are retained (disabled), not deleted.
if got := countTriggers(t, database, "triggered-wf", false); got != 3 {
t.Errorf("total triggers after Disable = %d, want 3", got)
}
if err := Reenable(ctx, database, "triggered-wf"); err != nil {
t.Fatalf("Reenable: %v", err)
}
if got := countTriggers(t, database, "triggered-wf", true); got != 3 {
t.Errorf("enabled triggers after Reenable = %d, want 3", got)
}
}