✨ (api): Add JSON REST API under /api/v1
Changes
16 files changed, +1389 -2
MODIFY
README.md
+32 -2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+}