gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (ci): Add secrets & variables management UI

496a10e97e6857f72108ad816c16bd0dcf1fbf60 · Michael Hainz · 2026-07-22T12:57:53Z

Changes

9 files changed, +514 -14

MODIFY README.md +3 -2
diff --git a/README.md b/README.md
index 7a07f29..a81ef88 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docs/admins/ci-runners.md b/docs/admins/ci-runners.md
index 27a2ac2..9cb3591 100644
--- a/docs/admins/ci-runners.md
+++ b/docs/admins/ci-runners.md
@@ -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
diff --git a/docs/maintainers/ci-runners.md b/docs/maintainers/ci-runners.md
index e23f572..0c2d391 100644
--- a/docs/maintainers/ci-runners.md
+++ b/docs/maintainers/ci-runners.md
@@ -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
diff --git a/docs/users/ci-runners.md b/docs/users/ci-runners.md
index 3909931..fecf684 100644
--- a/docs/users/ci-runners.md
+++ b/docs/users/ci-runners.md
@@ -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
diff --git a/src/main/java/de/workaround/ci/ActionSecretService.java b/src/main/java/de/workaround/ci/ActionSecretService.java
new file mode 100644
index 0000000..8273702
--- /dev/null
+++ b/src/main/java/de/workaround/ci/ActionSecretService.java
@@ -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
diff --git a/src/main/java/de/workaround/web/ActionSettingsResource.java b/src/main/java/de/workaround/web/ActionSettingsResource.java
new file mode 100644
index 0000000..035919e
--- /dev/null
+++ b/src/main/java/de/workaround/web/ActionSettingsResource.java
@@ -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
diff --git a/src/main/resources/templates/ActionSettingsResource/settings.html b/src/main/resources/templates/ActionSettingsResource/settings.html
new file mode 100644
index 0000000..0e156ef
--- /dev/null
+++ b/src/main/resources/templates/ActionSettingsResource/settings.html
@@ -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 &amp; 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
diff --git a/src/main/resources/templates/RepositoryResource/settings.html b/src/main/resources/templates/RepositoryResource/settings.html
index be34a5a..3c87cae 100644
--- a/src/main/resources/templates/RepositoryResource/settings.html
+++ b/src/main/resources/templates/RepositoryResource/settings.html
@@ -54,6 +54,12 @@
54 54 </section>
55 55
56 56 <section class="panel settings-card">
57 + <h2>CI secrets &amp; 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 &amp; 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
diff --git a/src/test/java/de/workaround/web/SecretsSettingsTest.java b/src/test/java/de/workaround/web/SecretsSettingsTest.java
new file mode 100644
index 0000000..039dde4
--- /dev/null
+++ b/src/test/java/de/workaround/web/SecretsSettingsTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog