✨ (ci): Add secrets & variables management UI
Changes
9 files changed, +514 -14
MODIFY
README.md
+3 -2
@@ -88,8 +88,9 @@
88
88
tokens and manage runners at `/admin/runners`; secrets are stored hashed. A push adding a workflow
89
89
to `.forgejo/workflows/` (`on: push`, with `branches`/`tags`/`paths` glob filters) creates a run,
90
90
which a runner claims, executes, and streams logs for — visible on the repository's **Actions** tab;
91
- a vanished runner's task is reclaimed after a timeout. Non-push events, secrets/variables,
92
- `needs`/`matrix`, and artifacts are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
91
+ a vanished runner's task is reclaimed after a timeout. Jobs are matched to runners by `runs-on`
92
+ labels, and repository owners manage encrypted secrets and variables that are delivered to runners.
93
+ Non-push events, `needs`/`matrix`, and artifacts are follow-up phases. Guides: [for users](docs/users/ci-runners.md), [for admins](docs/admins/ci-runners.md),
93
94
[architecture](docs/maintainers/ci-runners.md)
94
95
activities from; local users can in turn follow a remote repository — or a whole remote user, whose
95
96
public repositories are then followed and shown grouped — and read their pushes (see below)
MODIFY
docs/admins/ci-runners.md
+3 -3
@@ -77,9 +77,9 @@
77
77
78
78
- **Registration tokens and per-runner secrets** are stored only as SHA-256 hashes; each plaintext
79
79
is shown exactly once at creation and never again (same model as personal access tokens).
80
-- **Runner hosts are trusted infrastructure.** When the run loop and secrets delivery arrive, job
81
- secrets will be sent to whichever runner picks up a task; do not register runners you do not
82
- control. Fork-PR workflows with secrets are out of scope for now.
80
+- **Runner hosts are trusted infrastructure.** A repository's CI secrets are sent (decrypted, over
81
+ TLS) to whichever runner picks up one of its tasks; do not register runners you do not control.
82
+ Fork-PR workflows with secrets are out of scope for now (there are no PR triggers yet).
83
83
- Only handles in `GITSHARK_ADMIN_HANDLES` can generate tokens or see/delete runners.
84
84
85
85
## Tables
MODIFY
docs/maintainers/ci-runners.md
+5 -5
@@ -16,6 +16,7 @@
16
16
| Task progress | `ci/TaskProgressService.java` | UpdateTask (result → task status + run roll-up, runner back to IDLE) and UpdateLog (resume-safe log-row append, `ack_index`). |
17
17
| Zombie reclaim | `ci/ZombieReclaimService.java` | Scheduled sweep failing RUNNING tasks past their deadline (vanished runner) and rolling up their runs. |
18
18
| Actions UI | `web/ActionResource.java` + `templates/ActionResource/` | Read-only per-repo run list + run detail (jobs and their log rows); sidebar `Actions` tab. |
19
+| Secrets/variables UI | `web/ActionSettingsResource.java` + `ci/ActionSecretService.java` + `templates/ActionSettingsResource/` | Owner-only CRUD for CI secrets (write-only, encrypted) and variables at `settings/actions`. |
19
20
| Entities | `model/CiRunner.java`, `model/CiRunnerRegistrationToken.java` | Runner state (migration `V19`). |
20
21
| Run entities | `model/ActionRun.java`, `model/ActionTask.java`, `model/ActionLog.java` | Run/job/log-row persistence (migrations `V23`–`V25`). `ActionTask.seq` (`bigserial`) is the surrogate int64 `Task.id` for the wire; `ActionTask.runs_on` holds the job's labels for matching. |
21
22
| Secret/variable entities | `model/ActionSecret.java`, `model/ActionVariable.java` | Per-repo CI secrets (encrypted) and variables (migration `V26`), delivered to runners in FetchTask. |
@@ -106,7 +107,8 @@
106
107
`LabelMatchingTest` (runner claims a compatible task and skips an incompatible older one, gets
107
108
nothing when none match, unconstrained task runs anywhere),
108
109
`SecretDeliveryTest` (claimed task receives repo secrets decrypted + variables; empty fetch carries
109
- none).
110
+ none), `SecretsSettingsTest` (owner adds a secret stored encrypted and never shown, adds/deletes a
111
+ variable, duplicate-name rejected, stranger/anonymous get 404).
110
112
- **Zombie reclaim (`ZombieReclaimService`):** a scheduled sweep
111
113
(`gitshark.ci.zombie-reclaim-interval`, default 1m) fails any RUNNING task whose
112
114
`action_task.deadline` has passed — the runner is presumed gone — rolls its run up, and flags the
@@ -133,10 +135,8 @@
133
135
isolated/expanded into its own payload. No `needs`/`matrix` yet.
134
136
- **Non-push events:** only `push` is evaluated; `pull_request`, scheduled and manual triggers are
135
137
not. (`!`-negation within a single pattern list is also not supported.)
136
-- **Secrets/variables management UI:** delivery works, but there is no page yet to create/edit/delete
137
- them (tests seed the rows directly).
138
-- **Later phases:** concurrency/cancellation, artifacts (`ACTIONS_RESULTS_URL`), repo/org-scoped and
139
- ephemeral runners, commit/MR status.
138
+- **Later phases:** `needs`/`matrix`, concurrency/cancellation, artifacts (`ACTIONS_RESULTS_URL`),
139
+ repo/org-scoped and ephemeral runners, commit/MR status, non-push events.
140
140
141
141
## References
142
142
MODIFY
docs/users/ci-runners.md
+15 -4
@@ -7,8 +7,8 @@
7
7
> **Available today:** an **instance administrator** registers runners against this instance, and a
8
8
> push that adds a workflow to `.forgejo/workflows/` (or `.gitea/workflows/`) starts a run that a
9
9
> connected runner picks up and executes, with logs and results shown on the repository's **Actions**
10
-> tab. Triggers are limited to a plain `on: push` for now; richer triggers, secrets, and `needs`/
11
-> `matrix` arrive in later phases.
10
+> tab. Runs are triggered by `push` (with branch/tag/path filters); other events and `needs`/`matrix`
11
+> arrive in later phases.
12
12
13
13
## Running a workflow
14
14
@@ -55,8 +55,19 @@
55
55
A job runs only on a runner that advertises every label in its `runs-on` (e.g. `runs-on: ubuntu-latest`
56
56
needs a runner registered with the `ubuntu-latest` label); a job with no `runs-on` runs on any runner.
57
57
58
+## Secrets and variables
59
+
60
+Repository owners manage CI secrets and variables under **Settings → CI secrets & variables**. Both
61
+are delivered to the runner executing the repo's workflows and are available in the usual contexts —
62
+`${{ secrets.NAME }}` and `${{ vars.NAME }}`.
63
+
64
+- **Secrets** are encrypted at rest and **write-only**: once saved, the value is never shown again
65
+ (you can only replace it by deleting and re-adding). Storing secrets requires the instance to have
66
+ an encryption key configured.
67
+- **Variables** are plain configuration and their values are visible on the settings page.
68
+
58
69
## What's coming
59
70
60
-- Non-push events (`pull_request`, scheduled, manual).
61
-- Repository-level secrets and variables, `needs`/`matrix`, and run cancellation/re-run.
71
+- Non-push events (`pull_request`, scheduled, manual), `needs`/`matrix`.
72
+- Run cancellation / re-run.
62
73
- Artifacts and commit/merge-request status integration.
ADD
src/main/java/de/workaround/ci/ActionSecretService.java
+121 -0
@@ -0,0 +1,121 @@
1
+package de.workaround.ci;
2
+
3
+import java.util.List;
4
+import java.util.UUID;
5
+
6
+import de.workaround.mirror.SecretCrypto;
7
+import de.workaround.model.ActionSecret;
8
+import de.workaround.model.ActionVariable;
9
+import de.workaround.model.Repository;
10
+import jakarta.enterprise.context.ApplicationScoped;
11
+import jakarta.inject.Inject;
12
+import jakarta.transaction.Transactional;
13
+
14
+/**
15
+ * CRUD for repository CI secrets and variables (issue #2, phase 2). Callers must have already gated on
16
+ * repository-admin access; this layer validates input, enforces name uniqueness, encrypts secret
17
+ * values at rest, and only ever deletes rows belonging to the given repository. Validation problems
18
+ * are signalled with {@link IllegalArgumentException}; a missing encryption key with
19
+ * {@link IllegalStateException} — both surfaced to the user as a 400 by the resource.
20
+ */
21
+@ApplicationScoped
22
+public class ActionSecretService
23
+{
24
+ @Inject
25
+ ActionSecret.Repo secrets;
26
+
27
+ @Inject
28
+ ActionVariable.Repo variables;
29
+
30
+ @Inject
31
+ SecretCrypto crypto;
32
+
33
+ public List<ActionSecret> listSecrets(Repository repository)
34
+ {
35
+ return secrets.findByRepository(repository);
36
+ }
37
+
38
+ public List<ActionVariable> listVariables(Repository repository)
39
+ {
40
+ return variables.findByRepository(repository);
41
+ }
42
+
43
+ @Transactional
44
+ public void addSecret(Repository repository, String name, String value)
45
+ {
46
+ String cleanName = requireName(name);
47
+ requireValue(value);
48
+ if (!crypto.available())
49
+ {
50
+ throw new IllegalStateException("CI secrets need GITSHARK_SECRET_KEY configured to store them encrypted");
51
+ }
52
+ if (secrets.findByRepositoryAndName(repository, cleanName).isPresent())
53
+ {
54
+ throw new IllegalArgumentException("A secret named \"" + cleanName + "\" already exists");
55
+ }
56
+ ActionSecret secret = new ActionSecret();
57
+ secret.repository = repository;
58
+ secret.name = cleanName;
59
+ secret.valueEncrypted = crypto.encrypt(value);
60
+ secret.persist();
61
+ }
62
+
63
+ @Transactional
64
+ public void deleteSecret(Repository repository, UUID id)
65
+ {
66
+ ActionSecret secret = secrets.findById(id);
67
+ if (secret != null && secret.repository.id.equals(repository.id))
68
+ {
69
+ secret.delete();
70
+ }
71
+ }
72
+
73
+ @Transactional
74
+ public void addVariable(Repository repository, String name, String value)
75
+ {
76
+ String cleanName = requireName(name);
77
+ requireValue(value);
78
+ if (variables.findByRepositoryAndName(repository, cleanName).isPresent())
79
+ {
80
+ throw new IllegalArgumentException("A variable named \"" + cleanName + "\" already exists");
81
+ }
82
+ ActionVariable variable = new ActionVariable();
83
+ variable.repository = repository;
84
+ variable.name = cleanName;
85
+ variable.value = value;
86
+ variable.persist();
87
+ }
88
+
89
+ @Transactional
90
+ public void deleteVariable(Repository repository, UUID id)
91
+ {
92
+ ActionVariable variable = variables.findById(id);
93
+ if (variable != null && variable.repository.id.equals(repository.id))
94
+ {
95
+ variable.delete();
96
+ }
97
+ }
98
+
99
+ private static String requireName(String name)
100
+ {
101
+ String trimmed = name == null ? "" : name.trim();
102
+ if (trimmed.isEmpty())
103
+ {
104
+ throw new IllegalArgumentException("A name is required");
105
+ }
106
+ if (!trimmed.matches("[A-Za-z_][A-Za-z0-9_]*"))
107
+ {
108
+ throw new IllegalArgumentException(
109
+ "Name must start with a letter or underscore and contain only letters, digits and underscores");
110
+ }
111
+ return trimmed;
112
+ }
113
+
114
+ private static void requireValue(String value)
115
+ {
116
+ if (value == null || value.isEmpty())
117
+ {
118
+ throw new IllegalArgumentException("A value is required");
119
+ }
120
+ }
121
+}
ADD
src/main/java/de/workaround/web/ActionSettingsResource.java
+146 -0
@@ -0,0 +1,146 @@
1
+package de.workaround.web;
2
+
3
+import java.net.URI;
4
+import java.util.List;
5
+import java.util.UUID;
6
+
7
+import de.workaround.account.CurrentUser;
8
+import de.workaround.ci.ActionSecretService;
9
+import de.workaround.git.AccessPolicy;
10
+import de.workaround.git.GitRepositoryService;
11
+import de.workaround.model.ActionSecret;
12
+import de.workaround.model.ActionVariable;
13
+import de.workaround.model.Repository;
14
+import io.quarkus.qute.CheckedTemplate;
15
+import io.quarkus.qute.TemplateInstance;
16
+import jakarta.inject.Inject;
17
+import jakarta.ws.rs.Consumes;
18
+import jakarta.ws.rs.GET;
19
+import jakarta.ws.rs.NotFoundException;
20
+import jakarta.ws.rs.POST;
21
+import jakarta.ws.rs.PathParam;
22
+import jakarta.ws.rs.Produces;
23
+import jakarta.ws.rs.core.Context;
24
+import jakarta.ws.rs.core.MediaType;
25
+import jakarta.ws.rs.core.Response;
26
+import jakarta.ws.rs.core.UriInfo;
27
+
28
+/**
29
+ * Owner-only management of a repository's CI secrets and variables (issue #2, phase 2). Secret values
30
+ * are write-only — never rendered back. Mirrors the settings idiom: resolve + admin-gate (404 to hide
31
+ * private repos), POST then 303 back, re-render with an error message on validation failure.
32
+ */
33
+@jakarta.ws.rs.Path("/repos/{owner}/{name}/settings/actions")
34
+@Produces(MediaType.TEXT_HTML)
35
+public class ActionSettingsResource
36
+{
37
+ @CheckedTemplate
38
+ static class Templates
39
+ {
40
+ static native TemplateInstance settings(Repository repo, RepoNav nav, String error,
41
+ List<ActionSecret> secrets, List<ActionVariable> variables);
42
+ }
43
+
44
+ @Inject
45
+ CurrentUser currentUser;
46
+
47
+ @Inject
48
+ GitRepositoryService service;
49
+
50
+ @Inject
51
+ AccessPolicy accessPolicy;
52
+
53
+ @Inject
54
+ RepoNavService repoNav;
55
+
56
+ @Inject
57
+ ActionSecretService actionSecrets;
58
+
59
+ @Context
60
+ UriInfo uriInfo;
61
+
62
+ @GET
63
+ public TemplateInstance page(@PathParam("owner") String owner, @PathParam("name") String name)
64
+ {
65
+ Repository repo = requireOwner(owner, name);
66
+ return render(repo, null);
67
+ }
68
+
69
+ @POST
70
+ @jakarta.ws.rs.Path("secrets")
71
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
72
+ public Response addSecret(@PathParam("owner") String owner, @PathParam("name") String name,
73
+ @jakarta.ws.rs.FormParam("name") String secretName, @jakarta.ws.rs.FormParam("value") String value)
74
+ {
75
+ Repository repo = requireOwner(owner, name);
76
+ try
77
+ {
78
+ actionSecrets.addSecret(repo, secretName, value);
79
+ return backToSettings(repo);
80
+ }
81
+ catch (IllegalArgumentException | IllegalStateException e)
82
+ {
83
+ return Response.status(Response.Status.BAD_REQUEST).entity(render(repo, e.getMessage())).build();
84
+ }
85
+ }
86
+
87
+ @POST
88
+ @jakarta.ws.rs.Path("secrets/{id}/delete")
89
+ public Response deleteSecret(@PathParam("owner") String owner, @PathParam("name") String name,
90
+ @PathParam("id") UUID id)
91
+ {
92
+ Repository repo = requireOwner(owner, name);
93
+ actionSecrets.deleteSecret(repo, id);
94
+ return backToSettings(repo);
95
+ }
96
+
97
+ @POST
98
+ @jakarta.ws.rs.Path("variables")
99
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
100
+ public Response addVariable(@PathParam("owner") String owner, @PathParam("name") String name,
101
+ @jakarta.ws.rs.FormParam("name") String varName, @jakarta.ws.rs.FormParam("value") String value)
102
+ {
103
+ Repository repo = requireOwner(owner, name);
104
+ try
105
+ {
106
+ actionSecrets.addVariable(repo, varName, value);
107
+ return backToSettings(repo);
108
+ }
109
+ catch (IllegalArgumentException e)
110
+ {
111
+ return Response.status(Response.Status.BAD_REQUEST).entity(render(repo, e.getMessage())).build();
112
+ }
113
+ }
114
+
115
+ @POST
116
+ @jakarta.ws.rs.Path("variables/{id}/delete")
117
+ public Response deleteVariable(@PathParam("owner") String owner, @PathParam("name") String name,
118
+ @PathParam("id") UUID id)
119
+ {
120
+ Repository repo = requireOwner(owner, name);
121
+ actionSecrets.deleteVariable(repo, id);
122
+ return backToSettings(repo);
123
+ }
124
+
125
+ private TemplateInstance render(Repository repo, String error)
126
+ {
127
+ return Templates.settings(repo, repoNav.build(repo, uriInfo), error,
128
+ actionSecrets.listSecrets(repo), actionSecrets.listVariables(repo));
129
+ }
130
+
131
+ private Response backToSettings(Repository repo)
132
+ {
133
+ return Response.seeOther(URI.create(
134
+ "/repos/" + repo.ownerHandle() + "/" + repo.name + "/settings/actions")).build();
135
+ }
136
+
137
+ private Repository requireOwner(String owner, String name)
138
+ {
139
+ Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
140
+ if (!accessPolicy.canAdmin(currentUser.get(), repo))
141
+ {
142
+ throw new NotFoundException();
143
+ }
144
+ return repo;
145
+ }
146
+}
ADD
src/main/resources/templates/ActionSettingsResource/settings.html
+59 -0
@@ -0,0 +1,59 @@
1
+{#include layout}
2
+{#title}CI secrets & variables – {repo.name}{/title}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='settings' /}
5
+ <section class="repo-main">
6
+ <p class="issue-back"><a href="/repos/{repo.ownerHandle}/{repo.name}/settings">← Settings</a></p>
7
+ <h2>CI secrets & variables</h2>
8
+ {#if error}<p class="error">{error}</p>{/if}
9
+
10
+ <section class="panel settings-card">
11
+ <h2>Secrets</h2>
12
+ <p class="muted">Encrypted at rest and delivered to runners as <code>secrets.*</code>. Values are never shown again.</p>
13
+ {#if secrets}
14
+ <ul class="secret-list">
15
+ {#for s in secrets}
16
+ <li class="secret-row">
17
+ <code class="secret-name">{s.name}</code>
18
+ <form class="inline" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/settings/actions/secrets/{s.id}/delete">
19
+ <button class="btn btn-danger btn-sm">Delete</button>
20
+ </form>
21
+ </li>
22
+ {/for}
23
+ </ul>
24
+ {#else}
25
+ <p class="muted">No secrets yet.</p>
26
+ {/if}
27
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/settings/actions/secrets" class="secret-add">
28
+ <p><label>Name <input name="name" placeholder="API_TOKEN" required></label></p>
29
+ <p><label>Value <input name="value" type="password" required></label></p>
30
+ <button class="btn btn-primary">Add secret</button>
31
+ </form>
32
+ </section>
33
+
34
+ <section class="panel settings-card">
35
+ <h2>Variables</h2>
36
+ <p class="muted">Plain configuration delivered to runners as <code>vars.*</code>.</p>
37
+ {#if variables}
38
+ <ul class="secret-list">
39
+ {#for v in variables}
40
+ <li class="secret-row">
41
+ <code class="secret-name">{v.name}</code> <span class="muted">{v.value}</span>
42
+ <form class="inline" method="post" action="/repos/{repo.ownerHandle}/{repo.name}/settings/actions/variables/{v.id}/delete">
43
+ <button class="btn btn-danger btn-sm">Delete</button>
44
+ </form>
45
+ </li>
46
+ {/for}
47
+ </ul>
48
+ {#else}
49
+ <p class="muted">No variables yet.</p>
50
+ {/if}
51
+ <form method="post" action="/repos/{repo.ownerHandle}/{repo.name}/settings/actions/variables" class="secret-add">
52
+ <p><label>Name <input name="name" placeholder="DEPLOY_ENV" required></label></p>
53
+ <p><label>Value <input name="value" required></label></p>
54
+ <button class="btn btn-primary">Add variable</button>
55
+ </form>
56
+ </section>
57
+ </section>
58
+</div>
59
+{/include}
MODIFY
src/main/resources/templates/RepositoryResource/settings.html
+6 -0
@@ -54,6 +54,12 @@
54
54
</section>
55
55
56
56
<section class="panel settings-card">
57
+ <h2>CI secrets & variables</h2>
58
+ <p class="muted">Secrets and variables delivered to CI runners that execute this repository's workflows.</p>
59
+ <p><a class="btn btn-secondary" href="/repos/{repo.ownerHandle}/{repo.name}/settings/actions">Manage secrets & variables</a></p>
60
+ </section>
61
+
62
+ <section class="panel settings-card">
57
63
<h2>Push mirrors</h2>
58
64
{#if mirrors}
59
65
<ul class="mirror-list">
ADD
src/test/java/de/workaround/web/SecretsSettingsTest.java
+156 -0
@@ -0,0 +1,156 @@
1
+package de.workaround.web;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.model.ActionSecret;
9
+import de.workaround.model.ActionVariable;
10
+import de.workaround.model.Repository;
11
+import de.workaround.model.User;
12
+import io.quarkus.test.junit.QuarkusTest;
13
+import io.quarkus.test.security.TestSecurity;
14
+import jakarta.inject.Inject;
15
+import jakarta.persistence.EntityManager;
16
+import jakarta.transaction.Transactional;
17
+
18
+import static io.restassured.RestAssured.given;
19
+import static org.hamcrest.Matchers.containsString;
20
+import static org.hamcrest.Matchers.not;
21
+import static org.junit.jupiter.api.Assertions.assertEquals;
22
+import static org.junit.jupiter.api.Assertions.assertTrue;
23
+
24
+/**
25
+ * The repository Actions settings UI (issue #2, phase 2): owners manage CI secrets and variables.
26
+ * Secret values are encrypted at rest and never rendered back; non-owners cannot see or change them.
27
+ */
28
+@QuarkusTest
29
+class SecretsSettingsTest
30
+{
31
+ private static final String OWNER = "sec-owner";
32
+
33
+ private static final String STRANGER = "sec-stranger";
34
+
35
+ @Inject
36
+ GitRepositoryService repositories;
37
+
38
+ @Inject
39
+ User.Repo users;
40
+
41
+ @Inject
42
+ ActionSecret.Repo secrets;
43
+
44
+ @Inject
45
+ ActionVariable.Repo variables;
46
+
47
+ @Inject
48
+ EntityManager em;
49
+
50
+ @Test
51
+ @TestSecurity(user = OWNER)
52
+ void ownerAddsSecretStoredEncryptedAndNeverShown()
53
+ {
54
+ Repository repo = repoOwnedBy(OWNER, "sec-a");
55
+
56
+ given().redirects().follow(false)
57
+ .formParam("name", "API_TOKEN").formParam("value", "s3cr3t")
58
+ .when().post(base(repo) + "/secrets")
59
+ .then().statusCode(303);
60
+
61
+ given().when().get(base(repo))
62
+ .then().statusCode(200)
63
+ .body(containsString("API_TOKEN"))
64
+ .body(not(containsString("s3cr3t")));
65
+
66
+ String stored = (String) em.createNativeQuery(
67
+ "select value_encrypted from action_secret where name = 'API_TOKEN'").getSingleResult();
68
+ assertTrue(stored.startsWith("enc1:"), "secret is encrypted at rest, got: " + stored);
69
+ }
70
+
71
+ @Test
72
+ @TestSecurity(user = OWNER)
73
+ void ownerAddsAndDeletesVariable()
74
+ {
75
+ Repository repo = repoOwnedBy(OWNER, "sec-b");
76
+
77
+ given().redirects().follow(false)
78
+ .formParam("name", "DEPLOY_ENV").formParam("value", "production")
79
+ .when().post(base(repo) + "/variables")
80
+ .then().statusCode(303);
81
+
82
+ given().when().get(base(repo)).then().statusCode(200)
83
+ .body(containsString("DEPLOY_ENV")).body(containsString("production"));
84
+
85
+ UUID id = variables.findByRepository(repo).get(0).id;
86
+ given().redirects().follow(false)
87
+ .when().post(base(repo) + "/variables/" + id + "/delete")
88
+ .then().statusCode(303);
89
+
90
+ assertTrue(variables.findByRepository(repo).isEmpty());
91
+ }
92
+
93
+ @Test
94
+ @TestSecurity(user = OWNER)
95
+ void duplicateSecretNameIsRejected()
96
+ {
97
+ Repository repo = repoOwnedBy(OWNER, "sec-c");
98
+ given().redirects().follow(false).formParam("name", "DUP").formParam("value", "a")
99
+ .when().post(base(repo) + "/secrets").then().statusCode(303);
100
+
101
+ given().formParam("name", "DUP").formParam("value", "b")
102
+ .when().post(base(repo) + "/secrets")
103
+ .then().statusCode(400).body(containsString("already"));
104
+
105
+ assertEquals(1, secrets.findByRepository(repo).size());
106
+ }
107
+
108
+ @Test
109
+ @TestSecurity(user = STRANGER)
110
+ void strangerCannotViewOrManage()
111
+ {
112
+ Repository repo = repoOwnedBy(OWNER, "sec-d");
113
+ given().when().get(base(repo)).then().statusCode(404);
114
+ given().redirects().follow(false).formParam("name", "X").formParam("value", "y")
115
+ .when().post(base(repo) + "/secrets").then().statusCode(404);
116
+ assertTrue(secrets.findByRepository(repo).isEmpty());
117
+ }
118
+
119
+ @Test
120
+ void anonymousCannotView()
121
+ {
122
+ Repository repo = repoOwnedBy(OWNER, "sec-e");
123
+ given().when().get(base(repo)).then().statusCode(404);
124
+ }
125
+
126
+ private static String base(Repository repo)
127
+ {
128
+ return "/repos/" + repo.ownerHandle() + "/" + repo.name + "/settings/actions";
129
+ }
130
+
131
+ private Repository repoOwnedBy(String ownerName, String repoName)
132
+ {
133
+ User owner = persistUser(ownerName);
134
+ persistUser(STRANGER);
135
+ return createRepo(owner, repoName);
136
+ }
137
+
138
+ @Transactional
139
+ Repository createRepo(User owner, String repoName)
140
+ {
141
+ return repositories.create(owner, repoName, Repository.Visibility.PUBLIC, null);
142
+ }
143
+
144
+ @Transactional
145
+ User persistUser(String name)
146
+ {
147
+ return users.findByOidcSubOptional(name).orElseGet(() ->
148
+ {
149
+ User user = new User();
150
+ user.oidcSub = name;
151
+ user.username = name;
152
+ user.persist();
153
+ return user;
154
+ });
155
+ }
156
+}