tenseleyflow/shithub / 1bfb40f

Browse files

actions/variables: store orchestrator and tests (S41c)

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
1bfb40fece746b6c87163b44794f62fb34f7fd44
Parents
a2030db
Tree
3a6616e

5 changed files

StatusFile+-
M internal/actions/queries/actions_variables.sql 12 2
M internal/actions/sqlc/actions_variables.sql.go 84 12
M internal/actions/sqlc/querier.go 2 0
A internal/actions/variables/store.go 269 0
A internal/actions/variables/store_test.go 255 0
internal/actions/queries/actions_variables.sqlmodified
@@ -17,17 +17,27 @@ RETURNING id, repo_id, org_id, name, value, created_by_user_id,
1717
           created_at, updated_at;
1818
 
1919
 -- name: ListRepoVariables :many
20
-SELECT id, name, value, created_at, updated_at
20
+SELECT id, name, value, created_by_user_id, created_at, updated_at
2121
 FROM actions_variables
2222
 WHERE repo_id = $1
2323
 ORDER BY name ASC;
2424
 
2525
 -- name: ListOrgVariables :many
26
-SELECT id, name, value, created_at, updated_at
26
+SELECT id, name, value, created_by_user_id, created_at, updated_at
2727
 FROM actions_variables
2828
 WHERE org_id = $1
2929
 ORDER BY name ASC;
3030
 
31
+-- name: GetRepoVariable :one
32
+SELECT id, name, value, created_by_user_id, created_at, updated_at
33
+FROM actions_variables
34
+WHERE repo_id = $1 AND name = $2;
35
+
36
+-- name: GetOrgVariable :one
37
+SELECT id, name, value, created_by_user_id, created_at, updated_at
38
+FROM actions_variables
39
+WHERE org_id = $1 AND name = $2;
40
+
3141
 -- name: DeleteRepoVariable :exec
3242
 DELETE FROM actions_variables WHERE repo_id = $1 AND name = $2;
3343
 
internal/actions/sqlc/actions_variables.sql.gomodified
@@ -39,19 +39,88 @@ func (q *Queries) DeleteRepoVariable(ctx context.Context, db DBTX, arg DeleteRep
3939
 	return err
4040
 }
4141
 
42
+const getOrgVariable = `-- name: GetOrgVariable :one
43
+SELECT id, name, value, created_by_user_id, created_at, updated_at
44
+FROM actions_variables
45
+WHERE org_id = $1 AND name = $2
46
+`
47
+
48
+type GetOrgVariableParams struct {
49
+	OrgID pgtype.Int8
50
+	Name  string
51
+}
52
+
53
+type GetOrgVariableRow struct {
54
+	ID              int64
55
+	Name            string
56
+	Value           string
57
+	CreatedByUserID pgtype.Int8
58
+	CreatedAt       pgtype.Timestamptz
59
+	UpdatedAt       pgtype.Timestamptz
60
+}
61
+
62
+func (q *Queries) GetOrgVariable(ctx context.Context, db DBTX, arg GetOrgVariableParams) (GetOrgVariableRow, error) {
63
+	row := db.QueryRow(ctx, getOrgVariable, arg.OrgID, arg.Name)
64
+	var i GetOrgVariableRow
65
+	err := row.Scan(
66
+		&i.ID,
67
+		&i.Name,
68
+		&i.Value,
69
+		&i.CreatedByUserID,
70
+		&i.CreatedAt,
71
+		&i.UpdatedAt,
72
+	)
73
+	return i, err
74
+}
75
+
76
+const getRepoVariable = `-- name: GetRepoVariable :one
77
+SELECT id, name, value, created_by_user_id, created_at, updated_at
78
+FROM actions_variables
79
+WHERE repo_id = $1 AND name = $2
80
+`
81
+
82
+type GetRepoVariableParams struct {
83
+	RepoID pgtype.Int8
84
+	Name   string
85
+}
86
+
87
+type GetRepoVariableRow struct {
88
+	ID              int64
89
+	Name            string
90
+	Value           string
91
+	CreatedByUserID pgtype.Int8
92
+	CreatedAt       pgtype.Timestamptz
93
+	UpdatedAt       pgtype.Timestamptz
94
+}
95
+
96
+func (q *Queries) GetRepoVariable(ctx context.Context, db DBTX, arg GetRepoVariableParams) (GetRepoVariableRow, error) {
97
+	row := db.QueryRow(ctx, getRepoVariable, arg.RepoID, arg.Name)
98
+	var i GetRepoVariableRow
99
+	err := row.Scan(
100
+		&i.ID,
101
+		&i.Name,
102
+		&i.Value,
103
+		&i.CreatedByUserID,
104
+		&i.CreatedAt,
105
+		&i.UpdatedAt,
106
+	)
107
+	return i, err
108
+}
109
+
42110
 const listOrgVariables = `-- name: ListOrgVariables :many
43
-SELECT id, name, value, created_at, updated_at
111
+SELECT id, name, value, created_by_user_id, created_at, updated_at
44112
 FROM actions_variables
45113
 WHERE org_id = $1
46114
 ORDER BY name ASC
47115
 `
48116
 
49117
 type ListOrgVariablesRow struct {
50
-	ID        int64
51
-	Name      string
52
-	Value     string
53
-	CreatedAt pgtype.Timestamptz
54
-	UpdatedAt pgtype.Timestamptz
118
+	ID              int64
119
+	Name            string
120
+	Value           string
121
+	CreatedByUserID pgtype.Int8
122
+	CreatedAt       pgtype.Timestamptz
123
+	UpdatedAt       pgtype.Timestamptz
55124
 }
56125
 
57126
 func (q *Queries) ListOrgVariables(ctx context.Context, db DBTX, orgID pgtype.Int8) ([]ListOrgVariablesRow, error) {
@@ -67,6 +136,7 @@ func (q *Queries) ListOrgVariables(ctx context.Context, db DBTX, orgID pgtype.In
67136
 			&i.ID,
68137
 			&i.Name,
69138
 			&i.Value,
139
+			&i.CreatedByUserID,
70140
 			&i.CreatedAt,
71141
 			&i.UpdatedAt,
72142
 		); err != nil {
@@ -81,18 +151,19 @@ func (q *Queries) ListOrgVariables(ctx context.Context, db DBTX, orgID pgtype.In
81151
 }
82152
 
83153
 const listRepoVariables = `-- name: ListRepoVariables :many
84
-SELECT id, name, value, created_at, updated_at
154
+SELECT id, name, value, created_by_user_id, created_at, updated_at
85155
 FROM actions_variables
86156
 WHERE repo_id = $1
87157
 ORDER BY name ASC
88158
 `
89159
 
90160
 type ListRepoVariablesRow struct {
91
-	ID        int64
92
-	Name      string
93
-	Value     string
94
-	CreatedAt pgtype.Timestamptz
95
-	UpdatedAt pgtype.Timestamptz
161
+	ID              int64
162
+	Name            string
163
+	Value           string
164
+	CreatedByUserID pgtype.Int8
165
+	CreatedAt       pgtype.Timestamptz
166
+	UpdatedAt       pgtype.Timestamptz
96167
 }
97168
 
98169
 func (q *Queries) ListRepoVariables(ctx context.Context, db DBTX, repoID pgtype.Int8) ([]ListRepoVariablesRow, error) {
@@ -108,6 +179,7 @@ func (q *Queries) ListRepoVariables(ctx context.Context, db DBTX, repoID pgtype.
108179
 			&i.ID,
109180
 			&i.Name,
110181
 			&i.Value,
182
+			&i.CreatedByUserID,
111183
 			&i.CreatedAt,
112184
 			&i.UpdatedAt,
113185
 		); err != nil {
internal/actions/sqlc/querier.gomodified
@@ -31,7 +31,9 @@ type Querier interface {
3131
 	EnqueueWorkflowRun(ctx context.Context, db DBTX, arg EnqueueWorkflowRunParams) (WorkflowRun, error)
3232
 	GetArtifactByID(ctx context.Context, db DBTX, id int64) (WorkflowArtifact, error)
3333
 	GetOrgSecret(ctx context.Context, db DBTX, arg GetOrgSecretParams) (GetOrgSecretRow, error)
34
+	GetOrgVariable(ctx context.Context, db DBTX, arg GetOrgVariableParams) (GetOrgVariableRow, error)
3435
 	GetRepoSecret(ctx context.Context, db DBTX, arg GetRepoSecretParams) (GetRepoSecretRow, error)
36
+	GetRepoVariable(ctx context.Context, db DBTX, arg GetRepoVariableParams) (GetRepoVariableRow, error)
3537
 	GetRunnerByID(ctx context.Context, db DBTX, id int64) (WorkflowRunner, error)
3638
 	GetRunnerByName(ctx context.Context, db DBTX, name string) (WorkflowRunner, error)
3739
 	GetRunnerByTokenHash(ctx context.Context, db DBTX, tokenHash []byte) (GetRunnerByTokenHashRow, error)
internal/actions/variables/store.goadded
@@ -0,0 +1,269 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package variables owns the orchestrator over `actions_variables`.
4
+// Variables are non-secret, plaintext configuration surfaced to workflows
5
+// through the `${{ vars.NAME }}` namespace. They share the same repo/org
6
+// scoping rules as actions secrets, but List/Get intentionally return values
7
+// because these rows are not sensitive and are operator-visible in settings UI.
8
+package variables
9
+
10
+import (
11
+	"context"
12
+	"errors"
13
+	"fmt"
14
+	"regexp"
15
+	"unicode/utf8"
16
+
17
+	"github.com/jackc/pgx/v5"
18
+	"github.com/jackc/pgx/v5/pgtype"
19
+	"github.com/jackc/pgx/v5/pgxpool"
20
+
21
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
22
+)
23
+
24
+// MaxValueChars mirrors the actions_variables_value_length CHECK constraint.
25
+const MaxValueChars = 4096
26
+
27
+// Deps wires the store against runtime infra.
28
+type Deps struct {
29
+	Pool *pgxpool.Pool
30
+}
31
+
32
+// Scope identifies which `actions_variables` row family a call targets.
33
+// Construct via RepoScope or OrgScope; RepoID and OrgID are mutually
34
+// exclusive (the table CHECK constraint enforces this server-side).
35
+type Scope struct {
36
+	RepoID int64
37
+	OrgID  int64
38
+}
39
+
40
+// RepoScope returns a repo-scoped Scope. Repo variables are visible only
41
+// to workflows running in that repo.
42
+func RepoScope(id int64) Scope { return Scope{RepoID: id} }
43
+
44
+// OrgScope returns an org-scoped Scope. Org variables are visible to
45
+// workflows running in any repo owned by the org.
46
+func OrgScope(id int64) Scope { return Scope{OrgID: id} }
47
+
48
+// IsRepo reports whether the scope addresses a repo. Mutex with IsOrg.
49
+func (s Scope) IsRepo() bool { return s.RepoID != 0 && s.OrgID == 0 }
50
+
51
+// IsOrg reports whether the scope addresses an org. Mutex with IsRepo.
52
+func (s Scope) IsOrg() bool { return s.OrgID != 0 && s.RepoID == 0 }
53
+
54
+// Variable is the public listing/lookup shape. Values are intentionally
55
+// present because variables are not secrets; use the secrets package for
56
+// sensitive data that must never render in the web UI.
57
+type Variable struct {
58
+	ID              int64
59
+	Name            string
60
+	Value           string
61
+	CreatedByUserID int64 // 0 when null
62
+	CreatedAt       pgtype.Timestamptz
63
+	UpdatedAt       pgtype.Timestamptz
64
+}
65
+
66
+// Errors surfaced by the store. Callers (web handlers, runner API)
67
+// map these to HTTP status codes.
68
+var (
69
+	// ErrInvalidScope: zero-or-both Scope fields. Programmer error.
70
+	ErrInvalidScope = errors.New("variables: scope must address exactly one of RepoID or OrgID")
71
+	// ErrInvalidName: name doesn't match the regex. User-recoverable.
72
+	ErrInvalidName = errors.New("variables: name must match ^[A-Za-z_][A-Za-z0-9_]*$ and be 1..100 chars")
73
+	// ErrValueTooLong: values are plaintext config, capped to keep UI and
74
+	// expression-eval memory bounded.
75
+	ErrValueTooLong = errors.New("variables: value must be 4096 chars or fewer")
76
+	// ErrNotFound: no row with the given name in this scope.
77
+	ErrNotFound = errors.New("variables: not found")
78
+)
79
+
80
+// nameRe mirrors the actions_variables_name_format CHECK in migration 0049.
81
+var nameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
82
+
83
+func validateName(name string) error {
84
+	if len(name) < 1 || len(name) > 100 {
85
+		return ErrInvalidName
86
+	}
87
+	if !nameRe.MatchString(name) {
88
+		return ErrInvalidName
89
+	}
90
+	return nil
91
+}
92
+
93
+func validateValue(value string) error {
94
+	if utf8.RuneCountInString(value) > MaxValueChars {
95
+		return ErrValueTooLong
96
+	}
97
+	return nil
98
+}
99
+
100
+// Set creates or updates a variable in scope. Empty string is valid:
101
+// `${{ vars.MISSING }}` already resolves to empty string, and operators may
102
+// intentionally pin the same value explicitly.
103
+func (d Deps) Set(ctx context.Context, scope Scope, name, value string, createdBy int64) error {
104
+	if !scope.IsRepo() && !scope.IsOrg() {
105
+		return ErrInvalidScope
106
+	}
107
+	if err := validateName(name); err != nil {
108
+		return err
109
+	}
110
+	if err := validateValue(value); err != nil {
111
+		return err
112
+	}
113
+	q := actionsdb.New()
114
+	creator := pgtype.Int8{Int64: createdBy, Valid: createdBy != 0}
115
+	switch {
116
+	case scope.IsRepo():
117
+		_, err := q.UpsertRepoVariable(ctx, d.Pool, actionsdb.UpsertRepoVariableParams{
118
+			RepoID:          pgtype.Int8{Int64: scope.RepoID, Valid: true},
119
+			Name:            name,
120
+			Value:           value,
121
+			CreatedByUserID: creator,
122
+		})
123
+		if err != nil {
124
+			return fmt.Errorf("variables: upsert repo: %w", err)
125
+		}
126
+	case scope.IsOrg():
127
+		_, err := q.UpsertOrgVariable(ctx, d.Pool, actionsdb.UpsertOrgVariableParams{
128
+			OrgID:           pgtype.Int8{Int64: scope.OrgID, Valid: true},
129
+			Name:            name,
130
+			Value:           value,
131
+			CreatedByUserID: creator,
132
+		})
133
+		if err != nil {
134
+			return fmt.Errorf("variables: upsert org: %w", err)
135
+		}
136
+	}
137
+	return nil
138
+}
139
+
140
+// Get returns one plaintext variable by name.
141
+func (d Deps) Get(ctx context.Context, scope Scope, name string) (Variable, error) {
142
+	if !scope.IsRepo() && !scope.IsOrg() {
143
+		return Variable{}, ErrInvalidScope
144
+	}
145
+	if err := validateName(name); err != nil {
146
+		return Variable{}, err
147
+	}
148
+	q := actionsdb.New()
149
+	switch {
150
+	case scope.IsRepo():
151
+		row, err := q.GetRepoVariable(ctx, d.Pool, actionsdb.GetRepoVariableParams{
152
+			RepoID: pgtype.Int8{Int64: scope.RepoID, Valid: true},
153
+			Name:   name,
154
+		})
155
+		if err != nil {
156
+			return Variable{}, mapGetErr(err)
157
+		}
158
+		return Variable{
159
+			ID:              row.ID,
160
+			Name:            row.Name,
161
+			Value:           row.Value,
162
+			CreatedByUserID: int64ValueOrZero(row.CreatedByUserID),
163
+			CreatedAt:       row.CreatedAt,
164
+			UpdatedAt:       row.UpdatedAt,
165
+		}, nil
166
+	case scope.IsOrg():
167
+		row, err := q.GetOrgVariable(ctx, d.Pool, actionsdb.GetOrgVariableParams{
168
+			OrgID: pgtype.Int8{Int64: scope.OrgID, Valid: true},
169
+			Name:  name,
170
+		})
171
+		if err != nil {
172
+			return Variable{}, mapGetErr(err)
173
+		}
174
+		return Variable{
175
+			ID:              row.ID,
176
+			Name:            row.Name,
177
+			Value:           row.Value,
178
+			CreatedByUserID: int64ValueOrZero(row.CreatedByUserID),
179
+			CreatedAt:       row.CreatedAt,
180
+			UpdatedAt:       row.UpdatedAt,
181
+		}, nil
182
+	}
183
+	return Variable{}, ErrInvalidScope
184
+}
185
+
186
+// List returns every variable in scope, sorted ascending for stable UI
187
+// rendering.
188
+func (d Deps) List(ctx context.Context, scope Scope) ([]Variable, error) {
189
+	if !scope.IsRepo() && !scope.IsOrg() {
190
+		return nil, ErrInvalidScope
191
+	}
192
+	q := actionsdb.New()
193
+	switch {
194
+	case scope.IsRepo():
195
+		rows, err := q.ListRepoVariables(ctx, d.Pool, pgtype.Int8{Int64: scope.RepoID, Valid: true})
196
+		if err != nil {
197
+			return nil, fmt.Errorf("variables: list repo: %w", err)
198
+		}
199
+		out := make([]Variable, len(rows))
200
+		for i, r := range rows {
201
+			out[i] = Variable{
202
+				ID:              r.ID,
203
+				Name:            r.Name,
204
+				Value:           r.Value,
205
+				CreatedByUserID: int64ValueOrZero(r.CreatedByUserID),
206
+				CreatedAt:       r.CreatedAt,
207
+				UpdatedAt:       r.UpdatedAt,
208
+			}
209
+		}
210
+		return out, nil
211
+	case scope.IsOrg():
212
+		rows, err := q.ListOrgVariables(ctx, d.Pool, pgtype.Int8{Int64: scope.OrgID, Valid: true})
213
+		if err != nil {
214
+			return nil, fmt.Errorf("variables: list org: %w", err)
215
+		}
216
+		out := make([]Variable, len(rows))
217
+		for i, r := range rows {
218
+			out[i] = Variable{
219
+				ID:              r.ID,
220
+				Name:            r.Name,
221
+				Value:           r.Value,
222
+				CreatedByUserID: int64ValueOrZero(r.CreatedByUserID),
223
+				CreatedAt:       r.CreatedAt,
224
+				UpdatedAt:       r.UpdatedAt,
225
+			}
226
+		}
227
+		return out, nil
228
+	}
229
+	return nil, ErrInvalidScope
230
+}
231
+
232
+// Delete removes a variable. Missing rows are treated as success so callers
233
+// can use Delete for cleanup without a read-before-write race.
234
+func (d Deps) Delete(ctx context.Context, scope Scope, name string) error {
235
+	if !scope.IsRepo() && !scope.IsOrg() {
236
+		return ErrInvalidScope
237
+	}
238
+	if err := validateName(name); err != nil {
239
+		return err
240
+	}
241
+	q := actionsdb.New()
242
+	switch {
243
+	case scope.IsRepo():
244
+		return q.DeleteRepoVariable(ctx, d.Pool, actionsdb.DeleteRepoVariableParams{
245
+			RepoID: pgtype.Int8{Int64: scope.RepoID, Valid: true},
246
+			Name:   name,
247
+		})
248
+	case scope.IsOrg():
249
+		return q.DeleteOrgVariable(ctx, d.Pool, actionsdb.DeleteOrgVariableParams{
250
+			OrgID: pgtype.Int8{Int64: scope.OrgID, Valid: true},
251
+			Name:  name,
252
+		})
253
+	}
254
+	return ErrInvalidScope
255
+}
256
+
257
+func mapGetErr(err error) error {
258
+	if errors.Is(err, pgx.ErrNoRows) {
259
+		return ErrNotFound
260
+	}
261
+	return fmt.Errorf("variables: get: %w", err)
262
+}
263
+
264
+func int64ValueOrZero(p pgtype.Int8) int64 {
265
+	if p.Valid {
266
+		return p.Int64
267
+	}
268
+	return 0
269
+}
internal/actions/variables/store_test.goadded
@@ -0,0 +1,255 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package variables_test
4
+
5
+import (
6
+	"context"
7
+	"errors"
8
+	"strings"
9
+	"testing"
10
+
11
+	"github.com/jackc/pgx/v5/pgtype"
12
+
13
+	"github.com/tenseleyFlow/shithub/internal/actions/variables"
14
+	orgsdb "github.com/tenseleyFlow/shithub/internal/orgs/sqlc"
15
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
16
+	"github.com/tenseleyFlow/shithub/internal/testing/dbtest"
17
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
18
+)
19
+
20
+const fixtureHash = "$argon2id$v=19$m=16384,t=1,p=1$" +
21
+	"AAAAAAAAAAAAAAAA$" +
22
+	"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
23
+
24
+type fx struct {
25
+	deps   variables.Deps
26
+	repoID int64
27
+	orgID  int64
28
+	userID int64
29
+}
30
+
31
+func setup(t *testing.T) fx {
32
+	t.Helper()
33
+	pool := dbtest.NewTestDB(t)
34
+	ctx := context.Background()
35
+
36
+	user, err := usersdb.New().CreateUser(ctx, pool, usersdb.CreateUserParams{
37
+		Username: "alice", DisplayName: "Alice", PasswordHash: fixtureHash,
38
+	})
39
+	if err != nil {
40
+		t.Fatalf("CreateUser: %v", err)
41
+	}
42
+	repo, err := reposdb.New().CreateRepo(ctx, pool, reposdb.CreateRepoParams{
43
+		OwnerUserID:   pgtype.Int8{Int64: user.ID, Valid: true},
44
+		Name:          "demo",
45
+		DefaultBranch: "trunk",
46
+		Visibility:    reposdb.RepoVisibilityPublic,
47
+	})
48
+	if err != nil {
49
+		t.Fatalf("CreateRepo: %v", err)
50
+	}
51
+	org, err := orgsdb.New().CreateOrg(ctx, pool, orgsdb.CreateOrgParams{
52
+		Slug:            "acme",
53
+		DisplayName:     "Acme",
54
+		Description:     "",
55
+		BillingEmail:    "ops@example.com",
56
+		CreatedByUserID: pgtype.Int8{Int64: user.ID, Valid: true},
57
+	})
58
+	if err != nil {
59
+		t.Fatalf("CreateOrg: %v", err)
60
+	}
61
+
62
+	return fx{
63
+		deps:   variables.Deps{Pool: pool},
64
+		repoID: repo.ID,
65
+		orgID:  org.ID,
66
+		userID: user.ID,
67
+	}
68
+}
69
+
70
+func TestSetGet_RoundTripRepoScope(t *testing.T) {
71
+	f := setup(t)
72
+	ctx := context.Background()
73
+	scope := variables.RepoScope(f.repoID)
74
+	if err := f.deps.Set(ctx, scope, "IMAGE_TAG", "2026.05", f.userID); err != nil {
75
+		t.Fatalf("Set: %v", err)
76
+	}
77
+	got, err := f.deps.Get(ctx, scope, "IMAGE_TAG")
78
+	if err != nil {
79
+		t.Fatalf("Get: %v", err)
80
+	}
81
+	if got.Value != "2026.05" {
82
+		t.Errorf("got %q want 2026.05", got.Value)
83
+	}
84
+	if got.CreatedByUserID != f.userID {
85
+		t.Errorf("CreatedByUserID = %d, want %d", got.CreatedByUserID, f.userID)
86
+	}
87
+}
88
+
89
+func TestSet_AllowsEmptyValue(t *testing.T) {
90
+	f := setup(t)
91
+	ctx := context.Background()
92
+	scope := variables.RepoScope(f.repoID)
93
+	if err := f.deps.Set(ctx, scope, "EMPTY", "", f.userID); err != nil {
94
+		t.Fatalf("Set empty value: %v", err)
95
+	}
96
+	got, err := f.deps.Get(ctx, scope, "EMPTY")
97
+	if err != nil {
98
+		t.Fatalf("Get: %v", err)
99
+	}
100
+	if got.Value != "" {
101
+		t.Errorf("got %q want empty string", got.Value)
102
+	}
103
+}
104
+
105
+func TestSet_OverwriteOnSameName(t *testing.T) {
106
+	f := setup(t)
107
+	ctx := context.Background()
108
+	scope := variables.RepoScope(f.repoID)
109
+	if err := f.deps.Set(ctx, scope, "TARGET_ENV", "staging", f.userID); err != nil {
110
+		t.Fatalf("Set staging: %v", err)
111
+	}
112
+	if err := f.deps.Set(ctx, scope, "TARGET_ENV", "production", f.userID); err != nil {
113
+		t.Fatalf("Set production: %v", err)
114
+	}
115
+	got, err := f.deps.Get(ctx, scope, "TARGET_ENV")
116
+	if err != nil {
117
+		t.Fatalf("Get: %v", err)
118
+	}
119
+	if got.Value != "production" {
120
+		t.Errorf("got %q want production", got.Value)
121
+	}
122
+}
123
+
124
+func TestSet_InvalidNameRejected(t *testing.T) {
125
+	f := setup(t)
126
+	ctx := context.Background()
127
+	scope := variables.RepoScope(f.repoID)
128
+	for _, name := range []string{"", "1leading-digit", "has space", "has-dash", "cafe!"} {
129
+		if err := f.deps.Set(ctx, scope, name, "v", f.userID); !errors.Is(err, variables.ErrInvalidName) {
130
+			t.Errorf("Set name=%q: expected ErrInvalidName, got %v", name, err)
131
+		}
132
+	}
133
+}
134
+
135
+func TestSet_ValueTooLongRejected(t *testing.T) {
136
+	f := setup(t)
137
+	ctx := context.Background()
138
+	scope := variables.RepoScope(f.repoID)
139
+	value := strings.Repeat("x", variables.MaxValueChars+1)
140
+	if err := f.deps.Set(ctx, scope, "TOO_BIG", value, f.userID); !errors.Is(err, variables.ErrValueTooLong) {
141
+		t.Errorf("Set long value: expected ErrValueTooLong, got %v", err)
142
+	}
143
+}
144
+
145
+func TestSet_InvalidScopeRejected(t *testing.T) {
146
+	f := setup(t)
147
+	ctx := context.Background()
148
+	for _, sc := range []variables.Scope{
149
+		{},
150
+		{RepoID: 1, OrgID: 2},
151
+	} {
152
+		if err := f.deps.Set(ctx, sc, "K", "v", 0); !errors.Is(err, variables.ErrInvalidScope) {
153
+			t.Errorf("Set scope=%+v: expected ErrInvalidScope, got %v", sc, err)
154
+		}
155
+	}
156
+}
157
+
158
+func TestList_ReturnsValuesSortedAndMetadata(t *testing.T) {
159
+	f := setup(t)
160
+	ctx := context.Background()
161
+	scope := variables.RepoScope(f.repoID)
162
+	for _, item := range []struct {
163
+		name  string
164
+		value string
165
+	}{
166
+		{"BBB", "two"},
167
+		{"AAA", "one"},
168
+		{"ccc", "three"},
169
+	} {
170
+		if err := f.deps.Set(ctx, scope, item.name, item.value, f.userID); err != nil {
171
+			t.Fatalf("Set %s: %v", item.name, err)
172
+		}
173
+	}
174
+	got, err := f.deps.List(ctx, scope)
175
+	if err != nil {
176
+		t.Fatalf("List: %v", err)
177
+	}
178
+	if len(got) != 3 {
179
+		t.Fatalf("got %d variables, want 3", len(got))
180
+	}
181
+	wantNames := []string{"AAA", "BBB", "ccc"}
182
+	wantValues := []string{"one", "two", "three"}
183
+	for i := range wantNames {
184
+		if got[i].Name != wantNames[i] || got[i].Value != wantValues[i] {
185
+			t.Errorf("got[%d] = %s/%q, want %s/%q", i, got[i].Name, got[i].Value, wantNames[i], wantValues[i])
186
+		}
187
+		if got[i].CreatedByUserID != f.userID {
188
+			t.Errorf("got[%d].CreatedByUserID = %d, want %d", i, got[i].CreatedByUserID, f.userID)
189
+		}
190
+	}
191
+}
192
+
193
+func TestDelete_RemovesRow(t *testing.T) {
194
+	f := setup(t)
195
+	ctx := context.Background()
196
+	scope := variables.RepoScope(f.repoID)
197
+	if err := f.deps.Set(ctx, scope, "TOKEN", "v", f.userID); err != nil {
198
+		t.Fatalf("Set: %v", err)
199
+	}
200
+	if err := f.deps.Delete(ctx, scope, "TOKEN"); err != nil {
201
+		t.Fatalf("Delete: %v", err)
202
+	}
203
+	if _, err := f.deps.Get(ctx, scope, "TOKEN"); !errors.Is(err, variables.ErrNotFound) {
204
+		t.Errorf("Get after Delete: expected ErrNotFound, got %v", err)
205
+	}
206
+}
207
+
208
+func TestDelete_MissingIsIdempotent(t *testing.T) {
209
+	f := setup(t)
210
+	ctx := context.Background()
211
+	scope := variables.RepoScope(f.repoID)
212
+	if err := f.deps.Delete(ctx, scope, "NEVER_EXISTED"); err != nil {
213
+		t.Errorf("Delete missing: expected nil, got %v", err)
214
+	}
215
+}
216
+
217
+func TestGet_CitextNameIsCaseInsensitive(t *testing.T) {
218
+	f := setup(t)
219
+	ctx := context.Background()
220
+	scope := variables.RepoScope(f.repoID)
221
+	if err := f.deps.Set(ctx, scope, "IMAGE_TAG", "v1", f.userID); err != nil {
222
+		t.Fatalf("Set: %v", err)
223
+	}
224
+	got, err := f.deps.Get(ctx, scope, "image_tag")
225
+	if err != nil {
226
+		t.Fatalf("Get lowercased: %v", err)
227
+	}
228
+	if got.Value != "v1" {
229
+		t.Errorf("got %q want v1", got.Value)
230
+	}
231
+}
232
+
233
+func TestOrgAndRepoScopesAreIsolated(t *testing.T) {
234
+	f := setup(t)
235
+	ctx := context.Background()
236
+	repoScope := variables.RepoScope(f.repoID)
237
+	orgScope := variables.OrgScope(f.orgID)
238
+	if err := f.deps.Set(ctx, repoScope, "IMAGE_TAG", "repo", f.userID); err != nil {
239
+		t.Fatalf("Set repo: %v", err)
240
+	}
241
+	if err := f.deps.Set(ctx, orgScope, "IMAGE_TAG", "org", f.userID); err != nil {
242
+		t.Fatalf("Set org: %v", err)
243
+	}
244
+	repoVar, err := f.deps.Get(ctx, repoScope, "IMAGE_TAG")
245
+	if err != nil {
246
+		t.Fatalf("Get repo: %v", err)
247
+	}
248
+	orgVar, err := f.deps.Get(ctx, orgScope, "IMAGE_TAG")
249
+	if err != nil {
250
+		t.Fatalf("Get org: %v", err)
251
+	}
252
+	if repoVar.Value != "repo" || orgVar.Value != "org" {
253
+		t.Fatalf("scope bleed: repo=%q org=%q", repoVar.Value, orgVar.Value)
254
+	}
255
+}