gitshark

Clone repository

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

← Commits

✨ (api): Add JSON REST API under /api/v1

981e204d9ad678e0039d9e3327a5abc22781da2e · Miggi · 2026-07-08T15:28:16Z

Changes

16 files changed, +1389 -2

MODIFY README.md +32 -2
diff --git a/README.md b/README.md
index 9d59c60..5228669 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,8 @@
25 25 - Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author or by the repository owner. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
26 26 - OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
27 27 - Single access policy on all paths: owner read/write, public world-readable, private owner-only
28 +- **JSON REST API** under `/api/v1`, authenticated with the same personal access tokens as
29 + git-over-HTTP (`Authorization: Bearer <token>`), auto-documented via OpenAPI/Swagger UI (see below)
28 30 - **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
29 31 exposed as ForgeFed `Repository` actors that remote instances can follow and receive `Push`
30 32 activities from (see below)
@@ -55,6 +57,33 @@
55 57 > stable, non-loopback HTTPS origin before turning it on; git-shark refuses to emit actor documents
56 58 > otherwise.
57 59
60 +## REST API
61 +
62 +A JSON REST API is served under `/api/v1`, auto-documented via the existing
63 +`quarkus-smallrye-openapi` extension (`GET /q/openapi`, `GET /q/swagger-ui`).
64 +
65 +- Authenticated with the **same personal access tokens** used for git-over-HTTP, but sent as
66 + `Authorization: Bearer <token>` (not HTTP Basic)
67 +- Anonymous requests are allowed for public reads only; mutations require a token and repository
68 + ownership (posting a comment only requires read access)
69 +- Same visibility rules as the UI: private repositories are hidden as `404`, not `403`
70 +
71 +| Method | Path | Description |
72 +|---|---|---|
73 +| GET | `/api/v1/user` | The token owner (`401` without a valid token) |
74 +| GET | `/api/v1/repos` | Repositories visible to the caller |
75 +| POST | `/api/v1/repos` | Create a repository (`400` invalid name, `409` duplicate) |
76 +| GET | `/api/v1/repos/{owner}/{name}` | Repository detail |
77 +| DELETE | `/api/v1/repos/{owner}/{name}` | Delete a repository (owner only) |
78 +| GET, POST | `/api/v1/repos/{owner}/{name}/issues` | List / create issues |
79 +| GET, PATCH, DELETE | `/api/v1/repos/{owner}/{name}/issues/{number}` | Get / update status / delete an issue |
80 +| GET, POST | `/api/v1/repos/{owner}/{name}/merge-requests` | List / create merge requests |
81 +| GET | `/api/v1/repos/{owner}/{name}/merge-requests/{number}` | Merge request detail |
82 +| POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/merge` | Merge |
83 +| POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/close` | Close |
84 +| GET, POST | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments` | List / add line-level review comments (any reader may comment) |
85 +| DELETE | `/api/v1/repos/{owner}/{name}/merge-requests/{number}/comments/{commentId}` | Delete a comment (author or repo owner) |
86 +
58 87 ## Architecture notes
59 88
60 89 - Repository names resolve to UUID-based storage paths **through the database only**
@@ -83,8 +112,9 @@
83 112 | `GITSHARK_FEDERATION_MAX_ATTEMPTS` | `8` | Max delivery attempts before a queued activity is dead-lettered |
84 113 | `GITSHARK_FEDERATION_DEV_ALLOW_INSECURE` | `false` | **Dev/local only.** Lets the SSRF guard accept `http` + loopback/private targets so two instances can federate on one machine (peer allowlist still enforced). Never enable in production. |
85 114
86 -> **TLS required in production:** personal access tokens travel as HTTP Basic credentials.
87 -> Terminate TLS in front of the service; never expose plain HTTP publicly.
115 +> **TLS required in production:** personal access tokens travel as HTTP Basic credentials
116 +> (git-over-HTTP) or a `Bearer` token (REST API). Terminate TLS in front of the service; never
117 +> expose plain HTTP publicly.
88 118
89 119 ## Development
90 120
ADD src/main/java/de/workaround/api/ApiModels.java +88 -0
diff --git a/src/main/java/de/workaround/api/ApiModels.java b/src/main/java/de/workaround/api/ApiModels.java
new file mode 100644
index 0000000..b18eb2f
--- /dev/null
+++ b/src/main/java/de/workaround/api/ApiModels.java
@@ -0,0 +1,88 @@
1 +package de.workaround.api;
2 +
3 +import java.time.Instant;
4 +
5 +import de.workaround.model.Issue;
6 +import de.workaround.model.MergeRequest;
7 +import de.workaround.model.MergeRequestComment;
8 +import de.workaround.model.Repository;
9 +
10 +/**
11 + * JSON request and response shapes for the {@code /api/v1} surface. Response records are projected from
12 + * entities via their {@code of(...)} factories so the API contract never leaks lazy associations or
13 + * internal ids; request records are plain deserialization targets.
14 + */
15 +public final class ApiModels
16 +{
17 + private ApiModels()
18 + {
19 + }
20 +
21 + // -- responses --
22 +
23 + public record RepositoryView(String owner, String name, Repository.Visibility visibility, String description,
24 + Instant createdAt)
25 + {
26 + static RepositoryView of(Repository repo)
27 + {
28 + return new RepositoryView(repo.owner.username, repo.name, repo.visibility, repo.description,
29 + repo.createdAt);
30 + }
31 + }
32 +
33 + public record IssueView(int number, String title, String description, Issue.Status status, String author,
34 + Instant createdAt)
35 + {
36 + static IssueView of(Issue issue)
37 + {
38 + return new IssueView(issue.number, issue.title, issue.description, issue.status,
39 + issue.author.username, issue.createdAt);
40 + }
41 + }
42 +
43 + public record MergeRequestView(int number, String title, String description, String sourceBranch,
44 + String targetBranch, MergeRequest.Status status, String author, Instant createdAt, Instant mergedAt)
45 + {
46 + static MergeRequestView of(MergeRequest mr)
47 + {
48 + return new MergeRequestView(mr.number, mr.title, mr.description, mr.sourceBranch, mr.targetBranch,
49 + mr.status, mr.author.username, mr.createdAt, mr.mergedAt);
50 + }
51 + }
52 +
53 + public record CommentView(String id, String filePath, int oldLine, int newLine, String body, String author,
54 + Instant createdAt)
55 + {
56 + static CommentView of(MergeRequestComment comment)
57 + {
58 + return new CommentView(comment.id.toString(), comment.filePath, comment.oldLine, comment.newLine,
59 + comment.body, comment.author.username, comment.createdAt);
60 + }
61 + }
62 +
63 + public record UserView(String username, String displayName)
64 + {
65 + }
66 +
67 + // -- requests --
68 +
69 + public record NewRepository(String name, Repository.Visibility visibility, String description)
70 + {
71 + }
72 +
73 + public record NewIssue(String title, String description)
74 + {
75 + }
76 +
77 + public record IssueStatusUpdate(Issue.Status status)
78 + {
79 + }
80 +
81 + public record NewMergeRequest(String title, String description, String sourceBranch, String targetBranch)
82 + {
83 + }
84 +
85 + public record NewComment(String filePath, int oldLine, int newLine, String body)
86 + {
87 + }
88 +}
ADD src/main/java/de/workaround/api/ApiPrincipal.java +38 -0
diff --git a/src/main/java/de/workaround/api/ApiPrincipal.java b/src/main/java/de/workaround/api/ApiPrincipal.java
new file mode 100644
index 0000000..2ace67e
--- /dev/null
+++ b/src/main/java/de/workaround/api/ApiPrincipal.java
@@ -0,0 +1,38 @@
1 +package de.workaround.api;
2 +
3 +import de.workaround.model.User;
4 +import jakarta.enterprise.context.RequestScoped;
5 +import jakarta.ws.rs.NotAuthorizedException;
6 +
7 +/**
8 + * The user a {@code /api/v1} request authenticated as via its personal access token, or none for
9 + * anonymous requests. Populated per request by {@link ApiTokenAuthFilter}; resources read it to
10 + * apply the same read/write authorization rules the UI uses. Distinct from the UI's OIDC-backed
11 + * {@code CurrentUser}: API clients authenticate with a bearer token, not a browser session.
12 + */
13 +@RequestScoped
14 +public class ApiPrincipal
15 +{
16 + private User user;
17 +
18 + void set(User user)
19 + {
20 + this.user = user;
21 + }
22 +
23 + /** The authenticated user, or {@code null} for an anonymous (tokenless) request. */
24 + public User orNull()
25 + {
26 + return user;
27 + }
28 +
29 + /** The authenticated user, or 401 if the request carried no valid token. */
30 + public User require()
31 + {
32 + if (user == null)
33 + {
34 + throw new NotAuthorizedException("Bearer realm=\"git-shark-api\"");
35 + }
36 + return user;
37 + }
38 +}
ADD src/main/java/de/workaround/api/ApiTokenAuthFilter.java +50 -0
diff --git a/src/main/java/de/workaround/api/ApiTokenAuthFilter.java b/src/main/java/de/workaround/api/ApiTokenAuthFilter.java
new file mode 100644
index 0000000..9ad023d
--- /dev/null
+++ b/src/main/java/de/workaround/api/ApiTokenAuthFilter.java
@@ -0,0 +1,50 @@
1 +package de.workaround.api;
2 +
3 +import java.io.IOException;
4 +
5 +import de.workaround.http.AccessTokenService;
6 +import jakarta.inject.Inject;
7 +import jakarta.ws.rs.NotAuthorizedException;
8 +import jakarta.ws.rs.container.ContainerRequestContext;
9 +import jakarta.ws.rs.container.ContainerRequestFilter;
10 +import jakarta.ws.rs.core.HttpHeaders;
11 +import jakarta.ws.rs.ext.Provider;
12 +
13 +/**
14 + * Authenticates {@code /api/**} requests from an {@code Authorization: Bearer <token>} header, where the
15 + * token is a personal access token (the same secret used for Git-over-HTTP). A valid token populates
16 + * {@link ApiPrincipal}; a present-but-invalid token is rejected with 401. Requests without a token continue
17 + * anonymously — resources decide whether anonymous access is enough (public reads) or not (mutations).
18 + */
19 +@Provider
20 +public class ApiTokenAuthFilter implements ContainerRequestFilter
21 +{
22 + private static final String BEARER = "Bearer ";
23 +
24 + @Inject
25 + AccessTokenService tokens;
26 +
27 + @Inject
28 + ApiPrincipal principal;
29 +
30 + @Override
31 + public void filter(ContainerRequestContext requestContext) throws IOException
32 + {
33 + String path = requestContext.getUriInfo().getPath();
34 + // getPath() is relative to the app root and carries no leading slash; be lenient either way.
35 + if (!path.startsWith("api/") && !path.startsWith("/api/"))
36 + {
37 + return;
38 + }
39 + String header = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
40 + if (header == null || !header.regionMatches(true, 0, BEARER, 0, BEARER.length()))
41 + {
42 + return;
43 + }
44 + String token = header.substring(BEARER.length()).trim();
45 + tokens.authenticate(token)
46 + .ifPresentOrElse(principal::set, () -> {
47 + throw new NotAuthorizedException("Bearer realm=\"git-shark-api\"");
48 + });
49 + }
50 +}
ADD src/main/java/de/workaround/api/IssueApiResource.java +118 -0
diff --git a/src/main/java/de/workaround/api/IssueApiResource.java b/src/main/java/de/workaround/api/IssueApiResource.java
new file mode 100644
index 0000000..680ed8d
--- /dev/null
+++ b/src/main/java/de/workaround/api/IssueApiResource.java
@@ -0,0 +1,118 @@
1 +package de.workaround.api;
2 +
3 +import java.util.List;
4 +
5 +import de.workaround.git.AccessPolicy;
6 +import de.workaround.git.GitRepositoryService;
7 +import de.workaround.git.InvalidIssueException;
8 +import de.workaround.git.IssueService;
9 +import de.workaround.model.Issue;
10 +import de.workaround.model.Repository;
11 +import de.workaround.model.User;
12 +import jakarta.inject.Inject;
13 +import jakarta.transaction.Transactional;
14 +import jakarta.ws.rs.Consumes;
15 +import jakarta.ws.rs.DELETE;
16 +import jakarta.ws.rs.GET;
17 +import jakarta.ws.rs.NotFoundException;
18 +import jakarta.ws.rs.PATCH;
19 +import jakarta.ws.rs.POST;
20 +import jakarta.ws.rs.Path;
21 +import jakarta.ws.rs.PathParam;
22 +import jakarta.ws.rs.Produces;
23 +import jakarta.ws.rs.core.MediaType;
24 +import jakarta.ws.rs.core.Response;
25 +
26 +/**
27 + * JSON REST access to a repository's issues under {@code /api/v1/repos/{owner}/{name}/issues}. Issues are
28 + * addressed by their per-repository number. Listing/reading follow repository read-visibility; creating,
29 + * transitioning and deleting require a token and repository ownership (enforced by {@link IssueService}).
30 + */
31 +@Path("/api/v1/repos/{owner}/{name}/issues")
32 +@Produces(MediaType.APPLICATION_JSON)
33 +public class IssueApiResource
34 +{
35 + @Inject
36 + GitRepositoryService repositories;
37 +
38 + @Inject
39 + IssueService issues;
40 +
41 + @Inject
42 + AccessPolicy accessPolicy;
43 +
44 + @Inject
45 + ApiPrincipal principal;
46 +
47 + @GET
48 + public List<ApiModels.IssueView> list(@PathParam("owner") String owner, @PathParam("name") String name)
49 + {
50 + Repository repo = requireReadable(owner, name);
51 + return issues.list(repo).stream().map(ApiModels.IssueView::of).toList();
52 + }
53 +
54 + @POST
55 + @Consumes(MediaType.APPLICATION_JSON)
56 + public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
57 + ApiModels.NewIssue request)
58 + {
59 + User user = principal.require();
60 + Repository repo = requireReadable(owner, name);
61 + Issue issue = issues.create(user, repo, request.title(), request.description());
62 + return Response.status(Response.Status.CREATED).entity(ApiModels.IssueView.of(issue)).build();
63 + }
64 +
65 + @GET
66 + @Path("{number}")
67 + public ApiModels.IssueView get(@PathParam("owner") String owner, @PathParam("name") String name,
68 + @PathParam("number") int number)
69 + {
70 + Repository repo = requireReadable(owner, name);
71 + return ApiModels.IssueView.of(require(repo, number));
72 + }
73 +
74 + @PATCH
75 + @Path("{number}")
76 + @Consumes(MediaType.APPLICATION_JSON)
77 + @Transactional
78 + public ApiModels.IssueView update(@PathParam("owner") String owner, @PathParam("name") String name,
79 + @PathParam("number") int number, ApiModels.IssueStatusUpdate request)
80 + {
81 + User user = principal.require();
82 + Repository repo = requireReadable(owner, name);
83 + Issue issue = require(repo, number);
84 + if (request.status() == null)
85 + {
86 + throw new InvalidIssueException("status must be provided");
87 + }
88 + issues.updateStatus(user, issue, request.status());
89 + return ApiModels.IssueView.of(require(repo, number));
90 + }
91 +
92 + @DELETE
93 + @Path("{number}")
94 + public Response delete(@PathParam("owner") String owner, @PathParam("name") String name,
95 + @PathParam("number") int number)
96 + {
97 + User user = principal.require();
98 + Repository repo = requireReadable(owner, name);
99 + Issue issue = require(repo, number);
100 + issues.delete(user, issue);
101 + return Response.noContent().build();
102 + }
103 +
104 + private Issue require(Repository repo, int number)
105 + {
106 + return issues.find(repo, number).orElseThrow(NotFoundException::new);
107 + }
108 +
109 + private Repository requireReadable(String owner, String name)
110 + {
111 + Repository repo = repositories.find(owner, name).orElseThrow(NotFoundException::new);
112 + if (!accessPolicy.canRead(principal.orNull(), repo))
113 + {
114 + throw new NotFoundException();
115 + }
116 + return repo;
117 + }
118 +}
ADD src/main/java/de/workaround/api/MergeRequestApiResource.java +177 -0
diff --git a/src/main/java/de/workaround/api/MergeRequestApiResource.java b/src/main/java/de/workaround/api/MergeRequestApiResource.java
new file mode 100644
index 0000000..89bd30a
--- /dev/null
+++ b/src/main/java/de/workaround/api/MergeRequestApiResource.java
@@ -0,0 +1,177 @@
1 +package de.workaround.api;
2 +
3 +import java.util.List;
4 +import java.util.UUID;
5 +
6 +import de.workaround.git.AccessPolicy;
7 +import de.workaround.git.GitRepositoryService;
8 +import de.workaround.git.MergeRequestCommentService;
9 +import de.workaround.git.MergeRequestService;
10 +import de.workaround.model.MergeRequest;
11 +import de.workaround.model.MergeRequestComment;
12 +import de.workaround.model.Repository;
13 +import de.workaround.model.User;
14 +import jakarta.inject.Inject;
15 +import jakarta.transaction.Transactional;
16 +import jakarta.ws.rs.Consumes;
17 +import jakarta.ws.rs.DELETE;
18 +import jakarta.ws.rs.GET;
19 +import jakarta.ws.rs.NotFoundException;
20 +import jakarta.ws.rs.POST;
21 +import jakarta.ws.rs.Path;
22 +import jakarta.ws.rs.PathParam;
23 +import jakarta.ws.rs.Produces;
24 +import jakarta.ws.rs.core.MediaType;
25 +import jakarta.ws.rs.core.Response;
26 +
27 +/**
28 + * JSON REST access to a repository's merge requests under {@code /api/v1/repos/{owner}/{name}/merge-requests}.
29 + * Merge requests are addressed by their per-repository number. Listing/reading follow repository read-visibility;
30 + * creating, merging and closing require a token and repository ownership (enforced by {@link MergeRequestService}).
31 + */
32 +@Path("/api/v1/repos/{owner}/{name}/merge-requests")
33 +@Produces(MediaType.APPLICATION_JSON)
34 +public class MergeRequestApiResource
35 +{
36 + @Inject
37 + GitRepositoryService repositories;
38 +
39 + @Inject
40 + MergeRequestService mergeRequests;
41 +
42 + @Inject
43 + MergeRequestCommentService comments;
44 +
45 + @Inject
46 + MergeRequestComment.Repo commentRepo;
47 +
48 + @Inject
49 + AccessPolicy accessPolicy;
50 +
51 + @Inject
52 + ApiPrincipal principal;
53 +
54 + @GET
55 + public List<ApiModels.MergeRequestView> list(@PathParam("owner") String owner, @PathParam("name") String name)
56 + {
57 + Repository repo = requireReadable(owner, name);
58 + return mergeRequests.list(repo).stream().map(ApiModels.MergeRequestView::of).toList();
59 + }
60 +
61 + @POST
62 + @Consumes(MediaType.APPLICATION_JSON)
63 + public Response create(@PathParam("owner") String owner, @PathParam("name") String name,
64 + ApiModels.NewMergeRequest request)
65 + {
66 + User user = principal.require();
67 + Repository repo = requireReadable(owner, name);
68 + MergeRequest mr = mergeRequests.create(user, repo, request.title(), request.description(),
69 + request.sourceBranch(), request.targetBranch());
70 + return Response.status(Response.Status.CREATED)
71 + .entity(ApiModels.MergeRequestView.of(mr)).build();
72 + }
73 +
74 + @GET
75 + @Path("{number}")
76 + public ApiModels.MergeRequestView get(@PathParam("owner") String owner, @PathParam("name") String name,
77 + @PathParam("number") int number)
78 + {
79 + Repository repo = requireReadable(owner, name);
80 + return ApiModels.MergeRequestView.of(require(repo, number));
81 + }
82 +
83 + @POST
84 + @Path("{number}/merge")
85 + @Transactional
86 + public ApiModels.MergeRequestView merge(@PathParam("owner") String owner, @PathParam("name") String name,
87 + @PathParam("number") int number)
88 + {
89 + User user = principal.require();
90 + Repository repo = requireReadable(owner, name);
91 + MergeRequest mr = require(repo, number);
92 + mergeRequests.merge(user, mr);
93 + return ApiModels.MergeRequestView.of(require(repo, number));
94 + }
95 +
96 + @POST
97 + @Path("{number}/close")
98 + @Transactional
99 + public ApiModels.MergeRequestView close(@PathParam("owner") String owner, @PathParam("name") String name,
100 + @PathParam("number") int number)
101 + {
102 + User user = principal.require();
103 + Repository repo = requireReadable(owner, name);
104 + MergeRequest mr = require(repo, number);
105 + mergeRequests.close(user, mr);
106 + return ApiModels.MergeRequestView.of(require(repo, number));
107 + }
108 +
109 + @GET
110 + @Path("{number}/comments")
111 + public List<ApiModels.CommentView> listComments(@PathParam("owner") String owner,
112 + @PathParam("name") String name, @PathParam("number") int number)
113 + {
114 + Repository repo = requireReadable(owner, name);
115 + MergeRequest mr = require(repo, number);
116 + return comments.list(mr).stream().map(ApiModels.CommentView::of).toList();
117 + }
118 +
119 + @POST
120 + @Path("{number}/comments")
121 + @Consumes(MediaType.APPLICATION_JSON)
122 + public Response addComment(@PathParam("owner") String owner, @PathParam("name") String name,
123 + @PathParam("number") int number, ApiModels.NewComment request)
124 + {
125 + User user = principal.require();
126 + Repository repo = requireReadable(owner, name);
127 + MergeRequest mr = require(repo, number);
128 + MergeRequestComment comment = comments.add(user, mr, request.filePath(), request.oldLine(),
129 + request.newLine(), request.body());
130 + return Response.status(Response.Status.CREATED)
131 + .entity(ApiModels.CommentView.of(comment)).build();
132 + }
133 +
134 + @DELETE
135 + @Path("{number}/comments/{commentId}")
136 + public Response deleteComment(@PathParam("owner") String owner,
137 + @PathParam("name") String name, @PathParam("number") int number, @PathParam("commentId") String commentId)
138 + {
139 + User user = principal.require();
140 + Repository repo = requireReadable(owner, name);
141 + MergeRequest mr = require(repo, number);
142 + MergeRequestComment comment = commentRepo.findById(parseId(commentId));
143 + if (comment == null || !comment.mergeRequest.id.equals(mr.id))
144 + {
145 + throw new NotFoundException();
146 + }
147 + comments.delete(user, comment);
148 + return Response.noContent().build();
149 + }
150 +
151 + private static UUID parseId(String id)
152 + {
153 + try
154 + {
155 + return UUID.fromString(id);
156 + }
157 + catch (IllegalArgumentException e)
158 + {
159 + throw new NotFoundException();
160 + }
161 + }
162 +
163 + private MergeRequest require(Repository repo, int number)
164 + {
165 + return mergeRequests.find(repo, number).orElseThrow(NotFoundException::new);
166 + }
167 +
168 + private Repository requireReadable(String owner, String name)
169 + {
170 + Repository repo = repositories.find(owner, name).orElseThrow(NotFoundException::new);
171 + if (!accessPolicy.canRead(principal.orNull(), repo))
172 + {
173 + throw new NotFoundException();
174 + }
175 + return repo;
176 + }
177 +}
ADD src/main/java/de/workaround/api/RepositoryApiResource.java +103 -0
diff --git a/src/main/java/de/workaround/api/RepositoryApiResource.java b/src/main/java/de/workaround/api/RepositoryApiResource.java
new file mode 100644
index 0000000..f1e4024
--- /dev/null
+++ b/src/main/java/de/workaround/api/RepositoryApiResource.java
@@ -0,0 +1,103 @@
1 +package de.workaround.api;
2 +
3 +import java.net.URI;
4 +import java.util.List;
5 +
6 +import de.workaround.git.AccessPolicy;
7 +import de.workaround.git.GitRepositoryService;
8 +import de.workaround.git.InvalidRepositoryNameException;
9 +import de.workaround.git.RepositoryAlreadyExistsException;
10 +import de.workaround.model.Repository;
11 +import de.workaround.model.User;
12 +import jakarta.inject.Inject;
13 +import jakarta.ws.rs.Consumes;
14 +import jakarta.ws.rs.DELETE;
15 +import jakarta.ws.rs.GET;
16 +import jakarta.ws.rs.NotFoundException;
17 +import jakarta.ws.rs.POST;
18 +import jakarta.ws.rs.Path;
19 +import jakarta.ws.rs.PathParam;
20 +import jakarta.ws.rs.Produces;
21 +import jakarta.ws.rs.core.MediaType;
22 +import jakarta.ws.rs.core.Response;
23 +
24 +/**
25 + * JSON REST access to repositories under {@code /api/v1/repos}. Reads follow the same visibility rule as the
26 + * UI (public repositories are world-readable, private ones owner-only, hidden as 404); mutations require a
27 + * valid access token and repository ownership.
28 + */
29 +@Path("/api/v1/repos")
30 +@Produces(MediaType.APPLICATION_JSON)
31 +public class RepositoryApiResource
32 +{
33 + @Inject
34 + GitRepositoryService service;
35 +
36 + @Inject
37 + AccessPolicy accessPolicy;
38 +
39 + @Inject
40 + ApiPrincipal principal;
41 +
42 + @GET
43 + public List<ApiModels.RepositoryView> list()
44 + {
45 + return service.listVisibleTo(principal.orNull()).stream().map(ApiModels.RepositoryView::of).toList();
46 + }
47 +
48 + @POST
49 + @Consumes(MediaType.APPLICATION_JSON)
50 + public Response create(ApiModels.NewRepository request)
51 + {
52 + User user = principal.require();
53 + Repository.Visibility visibility = request.visibility() == null
54 + ? Repository.Visibility.PUBLIC : request.visibility();
55 + String description = request.description() == null || request.description().isBlank()
56 + ? null : request.description();
57 + try
58 + {
59 + Repository repo = service.create(user, request.name(), visibility, description);
60 + return Response.created(URI.create("/api/v1/repos/" + user.username + "/" + repo.name))
61 + .entity(ApiModels.RepositoryView.of(repo)).build();
62 + }
63 + catch (InvalidRepositoryNameException e)
64 + {
65 + return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage())
66 + .type(MediaType.TEXT_PLAIN).build();
67 + }
68 + catch (RepositoryAlreadyExistsException e)
69 + {
70 + return Response.status(Response.Status.CONFLICT).entity(e.getMessage())
71 + .type(MediaType.TEXT_PLAIN).build();
72 + }
73 + }
74 +
75 + @GET
76 + @Path("{owner}/{name}")
77 + public ApiModels.RepositoryView get(@PathParam("owner") String owner, @PathParam("name") String name)
78 + {
79 + return ApiModels.RepositoryView.of(requireReadable(owner, name));
80 + }
81 +
82 + @DELETE
83 + @Path("{owner}/{name}")
84 + public Response delete(@PathParam("owner") String owner, @PathParam("name") String name)
85 + {
86 + User user = principal.require();
87 + Repository repo = requireReadable(owner, name);
88 + // delegate ownership enforcement to the service (throws ForbiddenOperationException -> 403)
89 + service.delete(user, repo);
90 + return Response.noContent().build();
91 + }
92 +
93 + private Repository requireReadable(String owner, String name)
94 + {
95 + Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
96 + if (!accessPolicy.canRead(principal.orNull(), repo))
97 + {
98 + // hide existence of private repositories
99 + throw new NotFoundException();
100 + }
101 + return repo;
102 + }
103 +}
ADD src/main/java/de/workaround/api/UserApiResource.java +27 -0
diff --git a/src/main/java/de/workaround/api/UserApiResource.java b/src/main/java/de/workaround/api/UserApiResource.java
new file mode 100644
index 0000000..39d6005
--- /dev/null
+++ b/src/main/java/de/workaround/api/UserApiResource.java
@@ -0,0 +1,27 @@
1 +package de.workaround.api;
2 +
3 +import de.workaround.model.User;
4 +import jakarta.inject.Inject;
5 +import jakarta.ws.rs.GET;
6 +import jakarta.ws.rs.Path;
7 +import jakarta.ws.rs.Produces;
8 +import jakarta.ws.rs.core.MediaType;
9 +
10 +/**
11 + * The identity a request authenticated as, under {@code /api/v1/user}. Requires a valid access token
12 + * (401 otherwise); handy for clients to verify their token and discover their own handle.
13 + */
14 +@Path("/api/v1/user")
15 +@Produces(MediaType.APPLICATION_JSON)
16 +public class UserApiResource
17 +{
18 + @Inject
19 + ApiPrincipal principal;
20 +
21 + @GET
22 + public ApiModels.UserView current()
23 + {
24 + User user = principal.require();
25 + return new ApiModels.UserView(user.username, user.displayName);
26 + }
27 +}
MODIFY src/main/java/de/workaround/git/IssueService.java +5 -0
diff --git a/src/main/java/de/workaround/git/IssueService.java b/src/main/java/de/workaround/git/IssueService.java
index 3ff5fa7..b90c8fa 100644
--- a/src/main/java/de/workaround/git/IssueService.java
+++ b/src/main/java/de/workaround/git/IssueService.java
@@ -60,6 +60,11 @@
60 60 return issues.findByRepositoryAndId(repository, id);
61 61 }
62 62
63 + public Optional<Issue> find(Repository repository, int number)
64 + {
65 + return issues.findByRepositoryAndNumber(repository, number);
66 + }
67 +
63 68 @Transactional
64 69 public void updateStatus(User actor, Issue issue, Issue.Status status)
65 70 {
MODIFY src/main/java/de/workaround/git/MergeRequestService.java +5 -0
diff --git a/src/main/java/de/workaround/git/MergeRequestService.java b/src/main/java/de/workaround/git/MergeRequestService.java
index 213e93c..1d3fd2f 100644
--- a/src/main/java/de/workaround/git/MergeRequestService.java
+++ b/src/main/java/de/workaround/git/MergeRequestService.java
@@ -92,6 +92,11 @@
92 92 return mergeRequests.findByRepositoryAndId(repository, id);
93 93 }
94 94
95 + public Optional<MergeRequest> find(Repository repository, int number)
96 + {
97 + return mergeRequests.findByRepositoryAndNumber(repository, number);
98 + }
99 +
95 100 /** The live diff (merge-base..source) of the proposed change, or empty if a branch has since disappeared. */
96 101 public Optional<GitMergeService.DiffView> diff(MergeRequest mr)
97 102 {
MODIFY src/main/java/de/workaround/model/MergeRequest.java +3 -0
diff --git a/src/main/java/de/workaround/model/MergeRequest.java b/src/main/java/de/workaround/model/MergeRequest.java
index 5a56315..a30f073 100644
--- a/src/main/java/de/workaround/model/MergeRequest.java
+++ b/src/main/java/de/workaround/model/MergeRequest.java
@@ -80,6 +80,9 @@
80 80 @Find
81 81 Optional<MergeRequest> findByRepositoryAndId(Repository repository, UUID id);
82 82
83 + @Find
84 + Optional<MergeRequest> findByRepositoryAndNumber(Repository repository, int number);
85 +
83 86 @HQL("select count(mr) from MergeRequest mr where mr.repository = :repository and mr.status = OPEN")
84 87 long countOpen(Repository repository);
85 88
ADD src/test/java/de/workaround/api/IssueApiTest.java +142 -0
diff --git a/src/test/java/de/workaround/api/IssueApiTest.java b/src/test/java/de/workaround/api/IssueApiTest.java
new file mode 100644
index 0000000..f6e52c8
--- /dev/null
+++ b/src/test/java/de/workaround/api/IssueApiTest.java
@@ -0,0 +1,142 @@
1 +package de.workaround.api;
2 +
3 +import java.util.Map;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import de.workaround.git.GitRepositoryService;
9 +import de.workaround.http.AccessTokenService;
10 +import de.workaround.model.Repository;
11 +import de.workaround.model.User;
12 +import io.quarkus.test.junit.QuarkusTest;
13 +import jakarta.inject.Inject;
14 +import jakarta.transaction.Transactional;
15 +
16 +import static io.restassured.RestAssured.given;
17 +import static org.hamcrest.CoreMatchers.equalTo;
18 +
19 +@QuarkusTest
20 +class IssueApiTest
21 +{
22 + @Inject
23 + GitRepositoryService service;
24 +
25 + @Inject
26 + AccessTokenService tokenService;
27 +
28 + @Inject
29 + User.Repo userRepo;
30 +
31 + @Test
32 + void createListGetUpdateAndDeleteAnIssue()
33 + {
34 + User owner = persistUser("api-issue-owner");
35 + String token = mintToken(owner);
36 + service.create(owner, "tracker", Repository.Visibility.PUBLIC, null);
37 + String base = "/api/v1/repos/" + owner.username + "/tracker/issues";
38 +
39 + // create
40 + int number = given().header("Authorization", "Bearer " + token)
41 + .contentType("application/json")
42 + .body(Map.of("title", "First bug", "description", "boom"))
43 + .when().post(base)
44 + .then().statusCode(201)
45 + .body("title", equalTo("First bug"))
46 + .body("status", equalTo("PLANNED"))
47 + .body("author", equalTo(owner.username))
48 + .extract().path("number");
49 +
50 + // list
51 + given().when().get(base)
52 + .then().statusCode(200)
53 + .body("title", org.hamcrest.Matchers.hasItem("First bug"));
54 +
55 + // get by number
56 + given().when().get(base + "/" + number)
57 + .then().statusCode(200)
58 + .body("title", equalTo("First bug"));
59 +
60 + // update status
61 + given().header("Authorization", "Bearer " + token)
62 + .contentType("application/json")
63 + .body(Map.of("status", "DONE"))
64 + .when().patch(base + "/" + number)
65 + .then().statusCode(200)
66 + .body("status", equalTo("DONE"));
67 +
68 + // delete
69 + given().header("Authorization", "Bearer " + token)
70 + .when().delete(base + "/" + number)
71 + .then().statusCode(204);
72 +
73 + given().when().get(base + "/" + number)
74 + .then().statusCode(404);
75 + }
76 +
77 + @Test
78 + void anonymousCannotCreateIssues()
79 + {
80 + User owner = persistUser("api-issue-anon-" + shortId());
81 + service.create(owner, "tracker", Repository.Visibility.PUBLIC, null);
82 +
83 + given().contentType("application/json")
84 + .body(Map.of("title", "sneaky"))
85 + .when().post("/api/v1/repos/" + owner.username + "/tracker/issues")
86 + .then().statusCode(401);
87 + }
88 +
89 + @Test
90 + void strangerCannotCreateIssues()
91 + {
92 + User owner = persistUser("api-issue-owner2-" + shortId());
93 + service.create(owner, "tracker", Repository.Visibility.PUBLIC, null);
94 + User stranger = persistUser("api-issue-stranger-" + shortId());
95 + String token = mintToken(stranger);
96 +
97 + given().header("Authorization", "Bearer " + token)
98 + .contentType("application/json")
99 + .body(Map.of("title", "sneaky"))
100 + .when().post("/api/v1/repos/" + owner.username + "/tracker/issues")
101 + .then().statusCode(403);
102 + }
103 +
104 + @Test
105 + void blankTitleIsRejected()
106 + {
107 + User owner = persistUser("api-issue-blank-" + shortId());
108 + String token = mintToken(owner);
109 + service.create(owner, "tracker", Repository.Visibility.PUBLIC, null);
110 +
111 + given().header("Authorization", "Bearer " + token)
112 + .contentType("application/json")
113 + .body(Map.of("title", " "))
114 + .when().post("/api/v1/repos/" + owner.username + "/tracker/issues")
115 + .then().statusCode(400);
116 + }
117 +
118 + private String mintToken(User user)
119 + {
120 + return tokenService.create(user, "api-test").plaintext();
121 + }
122 +
123 + private static String shortId()
124 + {
125 + return UUID.randomUUID().toString().substring(0, 8);
126 + }
127 +
128 + @Transactional
129 + User persistUser(String name)
130 + {
131 + User existing = userRepo.findByOidcSubOptional(name).orElse(null);
132 + if (existing != null)
133 + {
134 + return existing;
135 + }
136 + User user = new User();
137 + user.oidcSub = name;
138 + user.username = name;
139 + user.persist();
140 + return user;
141 + }
142 +}
ADD src/test/java/de/workaround/api/MergeRequestApiTest.java +167 -0
diff --git a/src/test/java/de/workaround/api/MergeRequestApiTest.java b/src/test/java/de/workaround/api/MergeRequestApiTest.java
new file mode 100644
index 0000000..0d4d060
--- /dev/null
+++ b/src/test/java/de/workaround/api/MergeRequestApiTest.java
@@ -0,0 +1,167 @@
1 +package de.workaround.api;
2 +
3 +import java.nio.charset.StandardCharsets;
4 +import java.nio.file.Files;
5 +import java.nio.file.Path;
6 +import java.util.Map;
7 +import java.util.UUID;
8 +
9 +import org.eclipse.jgit.api.Git;
10 +import org.eclipse.jgit.transport.RefSpec;
11 +import org.junit.jupiter.api.Test;
12 +
13 +import de.workaround.git.GitRepositoryService;
14 +import de.workaround.git.GitTestSeeder;
15 +import de.workaround.http.AccessTokenService;
16 +import de.workaround.model.Repository;
17 +import de.workaround.model.User;
18 +import io.quarkus.test.junit.QuarkusTest;
19 +import jakarta.inject.Inject;
20 +import jakarta.transaction.Transactional;
21 +
22 +import static io.restassured.RestAssured.given;
23 +import static org.hamcrest.CoreMatchers.equalTo;
24 +
25 +@QuarkusTest
26 +class MergeRequestApiTest
27 +{
28 + @Inject
29 + GitRepositoryService service;
30 +
31 + @Inject
32 + AccessTokenService tokenService;
33 +
34 + @Inject
35 + User.Repo userRepo;
36 +
37 + @Test
38 + void createListGetAndMergeAMergeRequest()
39 + {
40 + User owner = persistUser("api-mr-owner");
41 + String token = mintToken(owner);
42 + seed(owner, "board");
43 + String base = "/api/v1/repos/" + owner.username + "/board/merge-requests";
44 +
45 + int number = given().header("Authorization", "Bearer " + token)
46 + .contentType("application/json")
47 + .body(Map.of("title", "Ship it", "description", "adds feature",
48 + "sourceBranch", "feature", "targetBranch", "main"))
49 + .when().post(base)
50 + .then().statusCode(201)
51 + .body("title", equalTo("Ship it"))
52 + .body("status", equalTo("OPEN"))
53 + .body("sourceBranch", equalTo("feature"))
54 + .body("targetBranch", equalTo("main"))
55 + .extract().path("number");
56 +
57 + given().when().get(base)
58 + .then().statusCode(200)
59 + .body("title", org.hamcrest.Matchers.hasItem("Ship it"));
60 +
61 + given().when().get(base + "/" + number)
62 + .then().statusCode(200)
63 + .body("title", equalTo("Ship it"));
64 +
65 + given().header("Authorization", "Bearer " + token)
66 + .when().post(base + "/" + number + "/merge")
67 + .then().statusCode(200)
68 + .body("status", equalTo("MERGED"));
69 + }
70 +
71 + @Test
72 + void openMergeRequestCanBeClosed()
73 + {
74 + User owner = persistUser("api-mr-closer");
75 + String token = mintToken(owner);
76 + seed(owner, "closeboard");
77 + String base = "/api/v1/repos/" + owner.username + "/closeboard/merge-requests";
78 +
79 + int number = given().header("Authorization", "Bearer " + token)
80 + .contentType("application/json")
81 + .body(Map.of("title", "Never mind", "sourceBranch", "feature", "targetBranch", "main"))
82 + .when().post(base)
83 + .then().statusCode(201).extract().path("number");
84 +
85 + given().header("Authorization", "Bearer " + token)
86 + .when().post(base + "/" + number + "/close")
87 + .then().statusCode(200)
88 + .body("status", equalTo("CLOSED"));
89 + }
90 +
91 + @Test
92 + void unknownBranchIsRejected()
93 + {
94 + User owner = persistUser("api-mr-badbranch");
95 + String token = mintToken(owner);
96 + seed(owner, "bb");
97 +
98 + given().header("Authorization", "Bearer " + token)
99 + .contentType("application/json")
100 + .body(Map.of("title", "bad", "sourceBranch", "ghost", "targetBranch", "main"))
101 + .when().post("/api/v1/repos/" + owner.username + "/bb/merge-requests")
102 + .then().statusCode(400);
103 + }
104 +
105 + @Test
106 + void anonymousCannotCreateMergeRequests()
107 + {
108 + User owner = persistUser("api-mr-anon-" + shortId());
109 + seed(owner, "board");
110 +
111 + given().contentType("application/json")
112 + .body(Map.of("title", "sneaky", "sourceBranch", "feature", "targetBranch", "main"))
113 + .when().post("/api/v1/repos/" + owner.username + "/board/merge-requests")
114 + .then().statusCode(401);
115 + }
116 +
117 + /** Repo with a base commit on main and a divergent 'feature' branch adding feature.txt. */
118 + private Repository seed(User owner, String name)
119 + {
120 + try
121 + {
122 + Repository repo = service.create(owner, name, Repository.Visibility.PUBLIC, null);
123 + Path bare = service.repositoryPath(repo);
124 + GitTestSeeder.seed(bare, Map.of("base.txt", "base\n".getBytes(StandardCharsets.UTF_8)));
125 + Path work = Files.createTempDirectory("apimrseed");
126 + try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setDirectory(work.toFile()).call())
127 + {
128 + git.checkout().setCreateBranch(true).setName("feature").call();
129 + Files.writeString(work.resolve("feature.txt"), "feature\n");
130 + git.add().addFilepattern(".").call();
131 + git.commit().setMessage("feature work").setSign(false)
132 + .setAuthor("dev", "dev@example.com").setCommitter("dev", "dev@example.com").call();
133 + git.push().setRefSpecs(new RefSpec("feature:refs/heads/feature")).call();
134 + }
135 + return repo;
136 + }
137 + catch (Exception e)
138 + {
139 + throw new RuntimeException(e);
140 + }
141 + }
142 +
143 + private String mintToken(User user)
144 + {
145 + return tokenService.create(user, "api-test").plaintext();
146 + }
147 +
148 + private static String shortId()
149 + {
150 + return UUID.randomUUID().toString().substring(0, 8);
151 + }
152 +
153 + @Transactional
154 + User persistUser(String name)
155 + {
156 + User existing = userRepo.findByOidcSubOptional(name).orElse(null);
157 + if (existing != null)
158 + {
159 + return existing;
160 + }
161 + User user = new User();
162 + user.oidcSub = name;
163 + user.username = name;
164 + user.persist();
165 + return user;
166 + }
167 +}
ADD src/test/java/de/workaround/api/MergeRequestCommentApiTest.java +208 -0
diff --git a/src/test/java/de/workaround/api/MergeRequestCommentApiTest.java b/src/test/java/de/workaround/api/MergeRequestCommentApiTest.java
new file mode 100644
index 0000000..877e0e3
--- /dev/null
+++ b/src/test/java/de/workaround/api/MergeRequestCommentApiTest.java
@@ -0,0 +1,208 @@
1 +package de.workaround.api;
2 +
3 +import java.nio.charset.StandardCharsets;
4 +import java.nio.file.Files;
5 +import java.nio.file.Path;
6 +import java.util.HashMap;
7 +import java.util.Map;
8 +import java.util.UUID;
9 +
10 +import org.eclipse.jgit.api.Git;
11 +import org.eclipse.jgit.transport.RefSpec;
12 +import org.junit.jupiter.api.Test;
13 +
14 +import de.workaround.git.GitRepositoryService;
15 +import de.workaround.git.MergeRequestService;
16 +import de.workaround.git.GitTestSeeder;
17 +import de.workaround.http.AccessTokenService;
18 +import de.workaround.model.MergeRequest;
19 +import de.workaround.model.Repository;
20 +import de.workaround.model.User;
21 +import io.quarkus.test.junit.QuarkusTest;
22 +import jakarta.inject.Inject;
23 +import jakarta.transaction.Transactional;
24 +
25 +import static io.restassured.RestAssured.given;
26 +import static org.hamcrest.CoreMatchers.equalTo;
27 +
28 +@QuarkusTest
29 +class MergeRequestCommentApiTest
30 +{
31 + @Inject
32 + GitRepositoryService service;
33 +
34 + @Inject
35 + MergeRequestService mergeRequestService;
36 +
37 + @Inject
38 + AccessTokenService tokenService;
39 +
40 + @Inject
41 + User.Repo userRepo;
42 +
43 + @Test
44 + void readerCanCommentListAndDeleteOwnComment()
45 + {
46 + User owner = persistUser("api-c-owner");
47 + int number = seedRepoWithMergeRequest(owner, "cboard");
48 + String base = "/api/v1/repos/" + owner.username + "/cboard/merge-requests/" + number + "/comments";
49 +
50 + // a different authenticated user (reader of the public repo) can comment
51 + User reader = persistUser("api-c-reader");
52 + String readerToken = mintToken(reader);
53 +
54 + String commentId = given().header("Authorization", "Bearer " + readerToken)
55 + .contentType("application/json")
56 + .body(anchor("looks good"))
57 + .when().post(base)
58 + .then().statusCode(201)
59 + .body("body", equalTo("looks good"))
60 + .body("author", equalTo(reader.username))
61 + .body("filePath", equalTo("feature.txt"))
62 + .extract().path("id");
63 +
64 + // list shows it (public read, even anonymously)
65 + given().when().get(base)
66 + .then().statusCode(200)
67 + .body("body", org.hamcrest.Matchers.hasItem("looks good"));
68 +
69 + // author deletes it
70 + given().header("Authorization", "Bearer " + readerToken)
71 + .when().delete(base + "/" + commentId)
72 + .then().statusCode(204);
73 + }
74 +
75 + @Test
76 + void anonymousCannotComment()
77 + {
78 + User owner = persistUser("api-c-anon-" + shortId());
79 + int number = seedRepoWithMergeRequest(owner, "cboard");
80 +
81 + given().contentType("application/json")
82 + .body(anchor("sneaky"))
83 + .when().post("/api/v1/repos/" + owner.username + "/cboard/merge-requests/" + number + "/comments")
84 + .then().statusCode(401);
85 + }
86 +
87 + @Test
88 + void commentMustAnchorToADiffLine()
89 + {
90 + User owner = persistUser("api-c-anchor-" + shortId());
91 + String token = mintToken(owner);
92 + int number = seedRepoWithMergeRequest(owner, "cboard");
93 +
94 + Map<String, Object> body = new HashMap<>();
95 + body.put("filePath", "feature.txt");
96 + body.put("oldLine", 999);
97 + body.put("newLine", 999);
98 + body.put("body", "nowhere");
99 +
100 + given().header("Authorization", "Bearer " + token)
101 + .contentType("application/json")
102 + .body(body)
103 + .when().post("/api/v1/repos/" + owner.username + "/cboard/merge-requests/" + number + "/comments")
104 + .then().statusCode(400);
105 + }
106 +
107 + @Test
108 + void repositoryOwnerCanDeleteAnotherUsersComment()
109 + {
110 + User owner = persistUser("api-c-owner2-" + shortId());
111 + String ownerToken = mintToken(owner);
112 + int number = seedRepoWithMergeRequest(owner, "cboard");
113 + String base = "/api/v1/repos/" + owner.username + "/cboard/merge-requests/" + number + "/comments";
114 +
115 + User reader = persistUser("api-c-reader2-" + shortId());
116 + String readerToken = mintToken(reader);
117 + String commentId = given().header("Authorization", "Bearer " + readerToken)
118 + .contentType("application/json").body(anchor("hi"))
119 + .when().post(base).then().statusCode(201).extract().path("id");
120 +
121 + given().header("Authorization", "Bearer " + ownerToken)
122 + .when().delete(base + "/" + commentId)
123 + .then().statusCode(204);
124 + }
125 +
126 + @Test
127 + void strangerCannotDeleteAnothersComment()
128 + {
129 + User owner = persistUser("api-c-owner3-" + shortId());
130 + int number = seedRepoWithMergeRequest(owner, "cboard");
131 + String base = "/api/v1/repos/" + owner.username + "/cboard/merge-requests/" + number + "/comments";
132 +
133 + User reader = persistUser("api-c-reader3-" + shortId());
134 + String readerToken = mintToken(reader);
135 + String commentId = given().header("Authorization", "Bearer " + readerToken)
136 + .contentType("application/json").body(anchor("mine"))
137 + .when().post(base).then().statusCode(201).extract().path("id");
138 +
139 + User stranger = persistUser("api-c-stranger-" + shortId());
140 + String strangerToken = mintToken(stranger);
141 + given().header("Authorization", "Bearer " + strangerToken)
142 + .when().delete(base + "/" + commentId)
143 + .then().statusCode(403);
144 + }
145 +
146 + /** An anchor on the single added line of feature.txt (added lines have no old-side number). */
147 + private static Map<String, Object> anchor(String body)
148 + {
149 + Map<String, Object> map = new HashMap<>();
150 + map.put("filePath", "feature.txt");
151 + map.put("oldLine", -1);
152 + map.put("newLine", 1);
153 + map.put("body", body);
154 + return map;
155 + }
156 +
157 + /** Public repo with a base commit on main and a 'feature' branch adding feature.txt, plus an open MR for it. */
158 + private int seedRepoWithMergeRequest(User owner, String name)
159 + {
160 + try
161 + {
162 + Repository repo = service.create(owner, name, Repository.Visibility.PUBLIC, null);
163 + Path bare = service.repositoryPath(repo);
164 + GitTestSeeder.seed(bare, Map.of("base.txt", "base\n".getBytes(StandardCharsets.UTF_8)));
165 + Path work = Files.createTempDirectory("apicseed");
166 + try (Git git = Git.cloneRepository().setURI(bare.toUri().toString()).setDirectory(work.toFile()).call())
167 + {
168 + git.checkout().setCreateBranch(true).setName("feature").call();
169 + Files.writeString(work.resolve("feature.txt"), "feature\n");
170 + git.add().addFilepattern(".").call();
171 + git.commit().setMessage("feature work").setSign(false)
172 + .setAuthor("dev", "dev@example.com").setCommitter("dev", "dev@example.com").call();
173 + git.push().setRefSpecs(new RefSpec("feature:refs/heads/feature")).call();
174 + }
175 + MergeRequest mr = mergeRequestService.create(owner, repo, "Review me", null, "feature", "main");
176 + return mr.number;
177 + }
178 + catch (Exception e)
179 + {
180 + throw new RuntimeException(e);
181 + }
182 + }
183 +
184 + private String mintToken(User user)
185 + {
186 + return tokenService.create(user, "api-test").plaintext();
187 + }
188 +
189 + private static String shortId()
190 + {
191 + return UUID.randomUUID().toString().substring(0, 8);
192 + }
193 +
194 + @Transactional
195 + User persistUser(String name)
196 + {
197 + User existing = userRepo.findByOidcSubOptional(name).orElse(null);
198 + if (existing != null)
199 + {
200 + return existing;
201 + }
202 + User user = new User();
203 + user.oidcSub = name;
204 + user.username = name;
205 + user.persist();
206 + return user;
207 + }
208 +}
ADD src/test/java/de/workaround/api/RepositoryApiTest.java +170 -0
diff --git a/src/test/java/de/workaround/api/RepositoryApiTest.java b/src/test/java/de/workaround/api/RepositoryApiTest.java
new file mode 100644
index 0000000..59f79c7
--- /dev/null
+++ b/src/test/java/de/workaround/api/RepositoryApiTest.java
@@ -0,0 +1,170 @@
1 +package de.workaround.api;
2 +
3 +import java.util.Map;
4 +import java.util.UUID;
5 +
6 +import org.junit.jupiter.api.Test;
7 +
8 +import de.workaround.git.GitRepositoryService;
9 +import de.workaround.http.AccessTokenService;
10 +import de.workaround.model.Repository;
11 +import de.workaround.model.User;
12 +import io.quarkus.test.junit.QuarkusTest;
13 +import jakarta.inject.Inject;
14 +import jakarta.transaction.Transactional;
15 +
16 +import static io.restassured.RestAssured.given;
17 +import static org.hamcrest.CoreMatchers.equalTo;
18 +import static org.hamcrest.CoreMatchers.is;
19 +
20 +@QuarkusTest
21 +class RepositoryApiTest
22 +{
23 + @Inject
24 + GitRepositoryService service;
25 +
26 + @Inject
27 + AccessTokenService tokenService;
28 +
29 + @Inject
30 + User.Repo userRepo;
31 +
32 + @Test
33 + void createListGetAndDeleteARepository()
34 + {
35 + User owner = persistUser("api-repo-owner");
36 + String token = mintToken(owner);
37 +
38 + // create
39 + String location = given().header("Authorization", "Bearer " + token)
40 + .contentType("application/json")
41 + .body(Map.of("name", "widgets", "visibility", "PUBLIC", "description", "gadgets"))
42 + .when().post("/api/v1/repos")
43 + .then().statusCode(201)
44 + .body("name", equalTo("widgets"))
45 + .body("owner", equalTo(owner.username))
46 + .body("visibility", equalTo("PUBLIC"))
47 + .body("description", equalTo("gadgets"))
48 + .extract().header("Location");
49 +
50 + // get via Location
51 + given().when().get(location)
52 + .then().statusCode(200)
53 + .body("name", equalTo("widgets"));
54 +
55 + // list includes it (public, visible even anonymously)
56 + given().when().get("/api/v1/repos")
57 + .then().statusCode(200)
58 + .body("name", org.hamcrest.Matchers.hasItem("widgets"));
59 +
60 + // delete
61 + given().header("Authorization", "Bearer " + token)
62 + .when().delete("/api/v1/repos/" + owner.username + "/widgets")
63 + .then().statusCode(204);
64 +
65 + given().when().get("/api/v1/repos/" + owner.username + "/widgets")
66 + .then().statusCode(404);
67 + }
68 +
69 + @Test
70 + void createRequiresAuthentication()
71 + {
72 + given().contentType("application/json")
73 + .body(Map.of("name", "nope", "visibility", "PUBLIC"))
74 + .when().post("/api/v1/repos")
75 + .then().statusCode(401);
76 + }
77 +
78 + @Test
79 + void duplicateNameConflicts()
80 + {
81 + User owner = persistUser("api-repo-dup");
82 + String token = mintToken(owner);
83 + service.create(owner, "dup", Repository.Visibility.PUBLIC, null);
84 +
85 + given().header("Authorization", "Bearer " + token)
86 + .contentType("application/json")
87 + .body(Map.of("name", "dup", "visibility", "PUBLIC"))
88 + .when().post("/api/v1/repos")
89 + .then().statusCode(409);
90 + }
91 +
92 + @Test
93 + void invalidNameIsRejected()
94 + {
95 + User owner = persistUser("api-repo-badname");
96 + String token = mintToken(owner);
97 +
98 + given().header("Authorization", "Bearer " + token)
99 + .contentType("application/json")
100 + .body(Map.of("name", "bad name!", "visibility", "PUBLIC"))
101 + .when().post("/api/v1/repos")
102 + .then().statusCode(400);
103 + }
104 +
105 + @Test
106 + void privateRepositoryIsHiddenFromStrangers()
107 + {
108 + User owner = persistUser("api-repo-priv-" + shortId());
109 + service.create(owner, "secret", Repository.Visibility.PRIVATE, null);
110 +
111 + // anonymous
112 + given().when().get("/api/v1/repos/" + owner.username + "/secret")
113 + .then().statusCode(404);
114 +
115 + // authenticated stranger
116 + User stranger = persistUser("api-repo-stranger-" + shortId());
117 + String token = mintToken(stranger);
118 + given().header("Authorization", "Bearer " + token)
119 + .when().get("/api/v1/repos/" + owner.username + "/secret")
120 + .then().statusCode(404);
121 + }
122 +
123 + @Test
124 + void nonOwnerCannotDelete()
125 + {
126 + User owner = persistUser("api-repo-victim-" + shortId());
127 + service.create(owner, "keepme", Repository.Visibility.PUBLIC, null);
128 + User stranger = persistUser("api-repo-thief-" + shortId());
129 + String token = mintToken(stranger);
130 +
131 + given().header("Authorization", "Bearer " + token)
132 + .when().delete("/api/v1/repos/" + owner.username + "/keepme")
133 + .then().statusCode(403);
134 + }
135 +
136 + @Test
137 + void unknownBearerTokenIsUnauthorized()
138 + {
139 + given().header("Authorization", "Bearer gs_not-a-real-token")
140 + .contentType("application/json")
141 + .body(Map.of("name", "x", "visibility", "PUBLIC"))
142 + .when().post("/api/v1/repos")
143 + .then().statusCode(401);
144 + }
145 +
146 + private String mintToken(User user)
147 + {
148 + return tokenService.create(user, "api-test").plaintext();
149 + }
150 +
151 + private static String shortId()
152 + {
153 + return UUID.randomUUID().toString().substring(0, 8);
154 + }
155 +
156 + @Transactional
157 + User persistUser(String name)
158 + {
159 + User existing = userRepo.findByOidcSubOptional(name).orElse(null);
160 + if (existing != null)
161 + {
162 + return existing;
163 + }
164 + User user = new User();
165 + user.oidcSub = name;
166 + user.username = name;
167 + user.persist();
168 + return user;
169 + }
170 +}
ADD src/test/java/de/workaround/api/UserApiTest.java +56 -0
diff --git a/src/test/java/de/workaround/api/UserApiTest.java b/src/test/java/de/workaround/api/UserApiTest.java
new file mode 100644
index 0000000..31dcb89
--- /dev/null
+++ b/src/test/java/de/workaround/api/UserApiTest.java
@@ -0,0 +1,56 @@
1 +package de.workaround.api;
2 +
3 +import org.junit.jupiter.api.Test;
4 +
5 +import de.workaround.http.AccessTokenService;
6 +import de.workaround.model.User;
7 +import io.quarkus.test.junit.QuarkusTest;
8 +import jakarta.inject.Inject;
9 +import jakarta.transaction.Transactional;
10 +
11 +import static io.restassured.RestAssured.given;
12 +import static org.hamcrest.CoreMatchers.equalTo;
13 +
14 +@QuarkusTest
15 +class UserApiTest
16 +{
17 + @Inject
18 + AccessTokenService tokenService;
19 +
20 + @Inject
21 + User.Repo userRepo;
22 +
23 + @Test
24 + void currentUserReturnsTheTokenOwner()
25 + {
26 + User user = persistUser("api-me");
27 + String token = tokenService.create(user, "api-test").plaintext();
28 +
29 + given().header("Authorization", "Bearer " + token)
30 + .when().get("/api/v1/user")
31 + .then().statusCode(200)
32 + .body("username", equalTo("api-me"));
33 + }
34 +
35 + @Test
36 + void currentUserWithoutTokenIsUnauthorized()
37 + {
38 + given().when().get("/api/v1/user")
39 + .then().statusCode(401);
40 + }
41 +
42 + @Transactional
43 + User persistUser(String name)
44 + {
45 + User existing = userRepo.findByOidcSubOptional(name).orElse(null);
46 + if (existing != null)
47 + {
48 + return existing;
49 + }
50 + User user = new User();
51 + user.oidcSub = name;
52 + user.username = name;
53 + user.persist();
54 + return user;
55 + }
56 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog