✨ (account): Add first-login onboarding to choose a URL-safe username
Changes
27 files changed, +1059 -17
MODIFY
README.md
+2 -2
@@ -14,8 +14,8 @@
14
14
- push and private read authenticate with **personal access tokens** (HTTP Basic password)
15
15
- Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
16
16
- public-key authentication only; keys managed per user in the UI
17
-- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser, commit log (paginated), branches & tags
18
-- OIDC login (authorization code flow) via `GET /login`; users provisioned on first login. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
17
+- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser, commit log (paginated), branches & tags, one-time handle selection (`/onboarding`), profile settings (`/settings/profile`)
18
+- 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)
19
19
- Single access policy on all paths: owner read/write, public world-readable, private owner-only
20
20
- **Federation (ForgeFed / ActivityPub)** — *opt-in, off by default.* Public repositories are
21
21
exposed as ForgeFed `Repository` actors that remote instances can follow and receive `Push`
ADD
openspec/changes/add-user-onboarding/.openspec.yaml
+2 -0
@@ -0,0 +1,2 @@
1
+schema: spec-driven
2
+created: 2026-06-27
ADD
openspec/changes/add-user-onboarding/design.md
+58 -0
@@ -0,0 +1,58 @@
1
+## Context
2
+
3
+git-shark is a server-rendered Quarkus + Qute app. User records are provisioned from OIDC claims in
4
+`CurrentUser.provision()`; `username` currently comes from `preferred_username` and is used directly
5
+as a URL path segment everywhere (`/repos/{owner}/{name}`, git HTTP/SSH, `/ap/users/{username}`,
6
+webfinger `acct:`). kanidm returns the SPN form `user@domain` for that claim, which is not URL-safe.
7
+The OIDC subject (`User.oidcSub`) is already the stable identity key. Auth is declarative
8
+(`quarkus.http.auth.permission.authenticated.paths`). Flyway owns the schema; Hibernate validates.
9
+
10
+## Goals / Non-Goals
11
+
12
+**Goals:**
13
+- Make the handle user-owned, URL-safe, unique, and decoupled from the IdP claim.
14
+- Force a one-time handle choice on first login before the app is usable.
15
+- Keep the IdP `name` claim as an editable display name.
16
+- Provide a settings-based rename path for existing/SPN-style users.
17
+
18
+**Non-Goals:**
19
+- Migrating or auto-rewriting existing SPN-style handles (handled via the rename page, not a batch job).
20
+- Changing git transport / SSH / federation auth.
21
+- Any client-side SPA work (the app is server-rendered).
22
+
23
+## Decisions
24
+
25
+- **Null `username` is the onboarding-pending signal.** No extra boolean column; a `null`/blank handle
26
+ means "not onboarded". Rationale: minimal schema change, and Postgres allows multiple NULLs under a
27
+ unique constraint so many un-onboarded accounts coexist. Alternative (a `onboardingCompleted` flag)
28
+ adds a column with no extra information.
29
+- **Stop deriving the handle from the claim in the JWT provisioning path only.** A new
30
+ `UserProvisioningService.provisionFromOidc(sub, claimUsername, displayName, email)` creates new users
31
+ with `username = null` (or, in dev with `adopt-username`, adopts a seeded row by `claimUsername`).
32
+ The existing `provision(sub, username, …)` is left intact so the non-JWT path (`@TestSecurity`,
33
+ basic/token auth) and `UserProvisioningServiceTest` keep setting a username. Rationale: avoids
34
+ breaking the entire existing UI test suite, which relies on auto-provisioned usernames, while the
35
+ real OIDC login path forces onboarding.
36
+- **Guard via a JAX-RS `ContainerRequestFilter`.** It redirects to `/onboarding` when the OIDC identity
37
+ is non-anonymous, the current user's handle is blank, and the path is not `onboarding`/`logout`.
38
+ Rationale: git/SSH/federation use their own (non-OIDC) auth, so they appear anonymous/non-session to
39
+ the filter and are skipped automatically — no path allowlisting of transport routes needed.
40
+- **Handle logic in small testable units.** `HandleSuggester.suggest(claim)` is a pure function;
41
+ `UsernameService.choose(user, handle)` validates (`^[a-z0-9][a-z0-9-]{0,38}$`) + uniqueness and throws
42
+ `InvalidUsernameException` / `UsernameTakenException`, mirroring the existing `InvalidSshKeyException`
43
+ pattern. Both onboarding and settings-rename reuse `choose`.
44
+
45
+## Risks / Trade-offs
46
+
47
+- [Blank-handle account reaches a URL-building code path before onboarding] → the guard runs before any
48
+ resource; templates for handle-less users are never rendered because every page redirects first.
49
+- [Dev adoption depends on the claim] → `provisionFromOidc` keeps the dev adopt branch keyed on
50
+ `claimUsername`, so seeded `alice` still logs in without onboarding.
51
+- [Unique constraint + NULLs] → relies on Postgres semantics (multiple NULLs allowed); the migration only
52
+ drops NOT NULL and keeps the existing unique index.
53
+
54
+## Migration Plan
55
+
56
+- Flyway `V4__username_nullable.sql`: `alter table users alter column username drop not null;`
57
+- Forward-only; rollback would require re-adding NOT NULL after backfilling handles. No data backfill —
58
+ existing rows already have handles.
ADD
openspec/changes/add-user-onboarding/proposal.md
+41 -0
@@ -0,0 +1,41 @@
1
+## Why
2
+
3
+The local `username` is copied verbatim from the OIDC `preferred_username` claim. With kanidm
4
+that claim is the SPN form `miggi@sso.mymiggi.de`, and because `username` is a path segment in
5
+every URL (repo paths `/repos/{owner}/{name}`, git HTTP/SSH clone URLs, ActivityPub actor IDs
6
+`/ap/users/{username}`, webfinger `acct:{username}@host`), the `@` and `.` produce broken, brittle,
7
+and confusing URLs. The handle that appears in URLs must be user-owned and URL-safe, not a raw IdP claim.
8
+
9
+## What Changes
10
+
11
+- On first OIDC login the local user record is created **without** a username; the IdP claim is no
12
+ longer persisted as the handle.
13
+- A one-time **onboarding page** (`/onboarding`) lets the user choose a URL-safe handle, pre-filled
14
+ with a sanitized suggestion derived from the `preferred_username` claim (e.g. `miggi@sso.mymiggi.de`
15
+ → `miggi`).
16
+- A guard redirects every authenticated app page to `/onboarding` until a handle is chosen.
17
+- The handle is validated (`^[a-z0-9][a-z0-9-]{0,38}$`), unique, and used in all repo/SSH/federation URLs.
18
+- The OIDC `name` claim is stored as a free-form **display name**, editable later.
19
+- Existing users can rename their handle and edit their display name via a new `/settings/profile` page.
20
+- **BREAKING** (data): `users.username` becomes nullable; a `null` handle marks an onboarding-pending account.
21
+
22
+## Capabilities
23
+
24
+### New Capabilities
25
+- `user-onboarding`: First-login handle selection — provisioning without a claim-derived handle,
26
+ the onboarding redirect guard, handle suggestion, handle validation/uniqueness, display-name
27
+ capture, and post-onboarding renaming via settings.
28
+
29
+### Modified Capabilities
30
+<!-- No existing capability spec covers user identity; provisioning lives only in create-git-platform design (D4). -->
31
+
32
+## Impact
33
+
34
+- Code: `account/CurrentUser`, `account/UserProvisioningService`, `model/User`,
35
+ new `account/OnboardingResource`, `account/OnboardingFilter`, `account/HandleSuggester`,
36
+ `account/UsernameService` (+ exceptions), `account/SettingsResource`.
37
+- Schema: new Flyway migration `V4__username_nullable.sql` (drop NOT NULL on `users.username`,
38
+ keep unique).
39
+- Config: add `/onboarding` to `quarkus.http.auth.permission.authenticated.paths`.
40
+- Templates: new `HomeResource/onboarding.html`, `SettingsResource/profile.html`.
41
+- Dev: seeded `alice` (handle already set) and the dev `adopt-username` path are unaffected.
ADD
openspec/changes/add-user-onboarding/specs/user-onboarding/spec.md
+115 -0
@@ -0,0 +1,115 @@
1
+## ADDED Requirements
2
+
3
+### Requirement: First-login provisioning without a claim-derived handle
4
+
5
+On first OIDC login the system SHALL create the local user record keyed by the OIDC subject (`sub`)
6
+and SHALL NOT persist the `preferred_username` claim as the handle. The `name` claim SHALL be stored
7
+as the display name and the `email` claim as the email. A newly provisioned user SHALL have no handle
8
+(onboarding pending) until one is chosen.
9
+
10
+#### Scenario: New OIDC subject is provisioned without a handle
11
+
12
+- **WHEN** a user logs in with an OIDC subject that has no local record
13
+- **THEN** a user record is created keyed by that subject with no handle set
14
+- **AND** the display name is taken from the `name` claim and email from the `email` claim
15
+
16
+#### Scenario: Returning user keeps the chosen handle
17
+
18
+- **WHEN** a user who has already chosen a handle logs in again
19
+- **THEN** their existing handle is preserved and display name/email are refreshed from the claims
20
+
21
+#### Scenario: Dev adopt-username path is preserved
22
+
23
+- **WHEN** dev `adopt-username` is enabled and an unknown subject logs in with a `preferred_username`
24
+ matching an existing seeded handle
25
+- **THEN** the existing record is re-keyed to the new subject and its handle is kept (no onboarding)
26
+
27
+### Requirement: Onboarding redirect guard
28
+
29
+The system SHALL redirect every authenticated application page to `/onboarding` while the current
30
+user has no handle, and SHALL NOT redirect once a handle is set. The guard SHALL NOT affect anonymous
31
+requests, the onboarding page itself, or logout. Git transport, SSH, and federation requests (which do
32
+not use the interactive OIDC session) SHALL be unaffected.
33
+
34
+#### Scenario: Handle-less user is redirected to onboarding
35
+
36
+- **WHEN** an authenticated user with no handle requests any application page (e.g. `/` or `/settings/keys`)
37
+- **THEN** the response is a redirect to `/onboarding`
38
+
39
+#### Scenario: Onboarded user reaches the app
40
+
41
+- **WHEN** an authenticated user with a handle requests an application page
42
+- **THEN** the request is served normally without redirect
43
+
44
+#### Scenario: Onboarding page itself is reachable
45
+
46
+- **WHEN** a handle-less user requests `/onboarding`
47
+- **THEN** the onboarding page is served (no redirect loop)
48
+
49
+### Requirement: Suggested handle
50
+
51
+The onboarding page SHALL pre-fill the handle field with a suggestion derived from the
52
+`preferred_username` claim by stripping any `@domain` part, lowercasing, replacing characters outside
53
+the allowed set with `-`, trimming leading/trailing separators, and clamping to the allowed length.
54
+
55
+#### Scenario: SPN claim is sanitized to a suggestion
56
+
57
+- **WHEN** the `preferred_username` claim is `miggi@sso.mymiggi.de`
58
+- **THEN** the suggested handle is `miggi`
59
+
60
+#### Scenario: Invalid characters are sanitized
61
+
62
+- **WHEN** the claim contains uppercase letters or characters outside `[a-z0-9-]`
63
+- **THEN** the suggestion is lowercased and the invalid characters are replaced or trimmed to fit the pattern
64
+
65
+### Requirement: Handle validation and uniqueness
66
+
67
+The system SHALL accept a handle only if it matches `^[a-z0-9][a-z0-9-]{0,38}$` and is not already used
68
+by another user. Invalid or already-taken handles SHALL be rejected with a clear error and SHALL NOT be
69
+persisted.
70
+
71
+#### Scenario: Valid unique handle is accepted
72
+
73
+- **WHEN** a handle-less user submits a handle matching the pattern that no other user holds
74
+- **THEN** the handle is stored on their record and they are redirected to the app
75
+
76
+#### Scenario: Invalid handle is rejected
77
+
78
+- **WHEN** a user submits a handle that does not match the pattern (bad characters, too long, or leading `-`)
79
+- **THEN** the submission is rejected with a validation error and no handle is stored
80
+
81
+#### Scenario: Taken handle is rejected
82
+
83
+- **WHEN** a user submits a handle already held by another user
84
+- **THEN** the submission is rejected with a uniqueness error and no handle is stored
85
+
86
+### Requirement: URLs use the chosen handle
87
+
88
+All repository, SSH, and federation URLs SHALL use the user's chosen handle and SHALL never expose the
89
+raw `preferred_username` SPN claim.
90
+
91
+#### Scenario: Repo and actor URLs use the handle
92
+
93
+- **WHEN** a user with handle `miggi` owns a repository
94
+- **THEN** its web path, clone URLs, ActivityPub actor ID, and webfinger `acct:` all use `miggi`, not the SPN claim
95
+
96
+### Requirement: Rename handle and edit display name in settings
97
+
98
+After onboarding, the system SHALL let a user change their handle (subject to the same validation and
99
+uniqueness rules) and edit their display name from a settings page. This provides the rename path for
100
+existing users with SPN-style handles.
101
+
102
+#### Scenario: User renames their handle
103
+
104
+- **WHEN** a user submits a new valid, unused handle on the settings profile page
105
+- **THEN** their handle is updated and subsequent URLs use the new handle
106
+
107
+#### Scenario: Rename collision is rejected
108
+
109
+- **WHEN** a user submits a handle already held by another user
110
+- **THEN** the rename is rejected with a uniqueness error and the handle is unchanged
111
+
112
+#### Scenario: Display name is editable
113
+
114
+- **WHEN** a user submits a new display name
115
+- **THEN** the display name is updated while the handle and OIDC subject are unchanged
ADD
openspec/changes/add-user-onboarding/tasks.md
+35 -0
@@ -0,0 +1,35 @@
1
+## 1. Data model + migration
2
+
3
+- [x] 1.1 Add `V4__username_nullable.sql` dropping NOT NULL on `users.username` (keep unique)
4
+- [x] 1.2 Document in `model/User.java` that a null/blank `username` means onboarding-pending
5
+
6
+## 2. Handle utilities (test-first)
7
+
8
+- [x] 2.1 Write `HandleSuggesterTest` (SPN strip, lowercase, invalid-char sanitize, clamp)
9
+- [x] 2.2 Implement `account/HandleSuggester.suggest(String claim)`
10
+- [x] 2.3 Write `UsernameValidationTest` (pattern, length, uniqueness collision)
11
+- [x] 2.4 Implement `account/UsernameService.choose(User, String handle)` + `InvalidUsernameException` / `UsernameTakenException`
12
+
13
+## 3. Provisioning
14
+
15
+- [x] 3.1 Write `UserProvisioningOnboardingTest` (provisionFromOidc creates null handle; dev adopt keeps handle)
16
+- [x] 3.2 Add `UserProvisioningService.provisionFromOidc(sub, claimUsername, displayName, email)`
17
+- [x] 3.3 Wire `CurrentUser` JWT branch to `provisionFromOidc`; leave non-JWT branch unchanged
18
+
19
+## 4. Onboarding page + guard
20
+
21
+- [x] 4.1 Write `OnboardingRedirectTest` (handle-less user 303 → /onboarding from / and /settings/keys; onboarded user not redirected)
22
+- [x] 4.2 Implement `account/OnboardingFilter` (ContainerRequestFilter redirect guard)
23
+- [x] 4.3 Write onboarding flow test (GET suggestion render; POST invalid/taken → error; POST valid → 303 /; revisit → 303 /)
24
+- [x] 4.4 Implement `account/OnboardingResource` (GET/POST) + `templates/HomeResource/onboarding.html`
25
+- [x] 4.5 Add `/onboarding` to `quarkus.http.auth.permission.authenticated.paths`
26
+
27
+## 5. Settings profile (rename + display name)
28
+
29
+- [x] 5.1 Write `SettingsProfileTest` (rename valid; collision rejected; display-name edit)
30
+- [x] 5.2 Add `GET`/`POST /settings/profile` to `SettingsResource` reusing `choose(...)` + `templates/SettingsResource/profile.html` + nav link
31
+
32
+## 6. Verify
33
+
34
+- [x] 6.1 Run full test suite green; format (`mvn formatter:format` + `mvn impsort:sort`)
35
+- [ ] 6.2 Manual dev check: fresh OIDC subject → onboarding → handle in repo/AP/webfinger URLs; rename via settings; seeded alice unaffected
MODIFY
src/main/java/de/workaround/account/CurrentUser.java
+4 -14
@@ -50,22 +50,12 @@
50
50
if (identity.getPrincipal() instanceof JsonWebToken jwt)
51
51
{
52
52
String sub = jwt.getSubject() != null ? jwt.getSubject() : name;
53
- String username = firstNonNull(jwt.getClaim("preferred_username"), name, sub);
54
- return provisioning.provision(sub, username, jwt.getClaim("name"), jwt.getClaim("email"));
53
+ // The handle is no longer taken from preferred_username: a new user picks a URL-safe one
54
+ // on the onboarding page. The claim is passed only for the dev adopt-username path.
55
+ return provisioning.provisionFromOidc(sub, jwt.getClaim("preferred_username"), jwt.getClaim("name"),
56
+ jwt.getClaim("email"));
55
57
}
56
58
return provisioning.provision(name, name, null, null);
57
59
}
58
60
59
- private static String firstNonNull(String... values)
60
- {
61
- for (String value : values)
62
- {
63
- if (value != null && !value.isBlank())
64
- {
65
- return value;
66
- }
67
- }
68
- return null;
69
- }
70
-
71
61
}
ADD
src/main/java/de/workaround/account/HandleSuggester.java
+35 -0
@@ -0,0 +1,35 @@
1
+package de.workaround.account;
2
+
3
+/**
4
+ * Derives a URL-safe handle suggestion from an OIDC {@code preferred_username} claim. With kanidm the
5
+ * claim is the SPN form {@code user@domain}; the suggestion strips the domain, lowercases, replaces
6
+ * characters outside the handle charset with {@code -}, trims separators, and clamps to the handle
7
+ * length. The result is only a pre-fill; the user may change it on the onboarding page.
8
+ */
9
+public final class HandleSuggester
10
+{
11
+ private static final int MAX_LENGTH = 39;
12
+
13
+ private HandleSuggester()
14
+ {
15
+ }
16
+
17
+ public static String suggest(String claim)
18
+ {
19
+ if (claim == null || claim.isBlank())
20
+ {
21
+ return "";
22
+ }
23
+ String local = claim.split("@", 2)[0];
24
+ String sanitized = local.toLowerCase()
25
+ .replaceAll("[^a-z0-9-]", "-")
26
+ .replaceAll("-{2,}", "-")
27
+ .replaceAll("^-+", "")
28
+ .replaceAll("-+$", "");
29
+ if (sanitized.length() > MAX_LENGTH)
30
+ {
31
+ sanitized = sanitized.substring(0, MAX_LENGTH).replaceAll("-+$", "");
32
+ }
33
+ return sanitized;
34
+ }
35
+}
ADD
src/main/java/de/workaround/account/InvalidUsernameException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.account;
2
+
3
+public class InvalidUsernameException extends RuntimeException
4
+{
5
+ public InvalidUsernameException(String message)
6
+ {
7
+ super(message);
8
+ }
9
+
10
+}
ADD
src/main/java/de/workaround/account/OnboardingFilter.java
+42 -0
@@ -0,0 +1,42 @@
1
+package de.workaround.account;
2
+
3
+import java.net.URI;
4
+
5
+import de.workaround.model.User;
6
+import jakarta.inject.Inject;
7
+import jakarta.ws.rs.container.ContainerRequestContext;
8
+import jakarta.ws.rs.container.ContainerRequestFilter;
9
+import jakarta.ws.rs.core.Response;
10
+import jakarta.ws.rs.ext.Provider;
11
+
12
+/**
13
+ * Redirects an authenticated user who has not yet chosen a handle to {@code /onboarding}. Anonymous
14
+ * requests (including git transport, SSH and federation, which use their own non-OIDC auth) are left
15
+ * alone, as are the onboarding page itself and logout.
16
+ */
17
+@Provider
18
+public class OnboardingFilter implements ContainerRequestFilter
19
+{
20
+ @Inject
21
+ CurrentUser currentUser;
22
+
23
+ @Override
24
+ public void filter(ContainerRequestContext context)
25
+ {
26
+ String path = context.getUriInfo().getPath();
27
+ if (path.startsWith("/"))
28
+ {
29
+ path = path.substring(1);
30
+ }
31
+ if (path.equals("onboarding") || path.equals("logout"))
32
+ {
33
+ return;
34
+ }
35
+ User user = currentUser.get();
36
+ if (user != null && (user.username == null || user.username.isBlank()))
37
+ {
38
+ context.abortWith(Response.seeOther(URI.create("/onboarding")).build());
39
+ }
40
+ }
41
+
42
+}
ADD
src/main/java/de/workaround/account/OnboardingResource.java
+82 -0
@@ -0,0 +1,82 @@
1
+package de.workaround.account;
2
+
3
+import java.net.URI;
4
+
5
+import de.workaround.model.User;
6
+import io.quarkus.qute.CheckedTemplate;
7
+import io.quarkus.qute.TemplateInstance;
8
+import io.quarkus.security.identity.SecurityIdentity;
9
+import jakarta.inject.Inject;
10
+import jakarta.ws.rs.Consumes;
11
+import jakarta.ws.rs.FormParam;
12
+import jakarta.ws.rs.GET;
13
+import jakarta.ws.rs.POST;
14
+import jakarta.ws.rs.Path;
15
+import jakarta.ws.rs.Produces;
16
+import jakarta.ws.rs.core.MediaType;
17
+import jakarta.ws.rs.core.Response;
18
+import org.eclipse.microprofile.jwt.JsonWebToken;
19
+
20
+/**
21
+ * One-time first-login page where a user picks a URL-safe handle. The OnboardingFilter forces every
22
+ * authenticated request here until a handle is set.
23
+ */
24
+@Path("/onboarding")
25
+@Produces(MediaType.TEXT_HTML)
26
+public class OnboardingResource
27
+{
28
+ @CheckedTemplate
29
+ static class Templates
30
+ {
31
+ static native TemplateInstance onboarding(String suggestedHandle, String displayName, String error);
32
+ }
33
+
34
+ @Inject
35
+ CurrentUser currentUser;
36
+
37
+ @Inject
38
+ SecurityIdentity identity;
39
+
40
+ @Inject
41
+ UsernameService usernames;
42
+
43
+ @GET
44
+ public Response onboarding()
45
+ {
46
+ User user = currentUser.require();
47
+ if (user.username != null && !user.username.isBlank())
48
+ {
49
+ return Response.seeOther(URI.create("/")).build();
50
+ }
51
+ return Response.ok(Templates.onboarding(suggestedHandle(), user.displayName, null)).build();
52
+ }
53
+
54
+ @POST
55
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
56
+ public Response submit(@FormParam("username") String username, @FormParam("displayName") String displayName)
57
+ {
58
+ User user = currentUser.require();
59
+ try
60
+ {
61
+ usernames.choose(user, username);
62
+ usernames.setDisplayName(user, displayName);
63
+ return Response.seeOther(URI.create("/")).build();
64
+ }
65
+ catch (InvalidUsernameException | UsernameTakenException e)
66
+ {
67
+ return Response.status(Response.Status.BAD_REQUEST)
68
+ .entity(Templates.onboarding(username, displayName, e.getMessage()))
69
+ .build();
70
+ }
71
+ }
72
+
73
+ private String suggestedHandle()
74
+ {
75
+ if (identity.getPrincipal() instanceof JsonWebToken jwt)
76
+ {
77
+ return HandleSuggester.suggest(jwt.getClaim("preferred_username"));
78
+ }
79
+ return "";
80
+ }
81
+
82
+}
MODIFY
src/main/java/de/workaround/account/SettingsResource.java
+33 -0
@@ -32,6 +32,8 @@
32
32
static native TemplateInstance tokens(List<AccessToken> tokens);
33
33
34
34
static native TemplateInstance tokenCreated(String plaintext);
35
+
36
+ static native TemplateInstance profile(String username, String displayName, String error);
35
37
}
36
38
37
39
@Inject
@@ -43,6 +45,37 @@
43
45
@Inject
44
46
AccessTokenService tokenService;
45
47
48
+ @Inject
49
+ UsernameService usernames;
50
+
51
+ @GET
52
+ @Path("/profile")
53
+ public TemplateInstance profile()
54
+ {
55
+ de.workaround.model.User user = currentUser.require();
56
+ return Templates.profile(user.username, user.displayName, null);
57
+ }
58
+
59
+ @POST
60
+ @Path("/profile")
61
+ @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
62
+ public Response updateProfile(@FormParam("username") String username, @FormParam("displayName") String displayName)
63
+ {
64
+ de.workaround.model.User user = currentUser.require();
65
+ try
66
+ {
67
+ usernames.choose(user, username);
68
+ usernames.setDisplayName(user, displayName);
69
+ return Response.seeOther(URI.create("/settings/profile")).build();
70
+ }
71
+ catch (InvalidUsernameException | UsernameTakenException e)
72
+ {
73
+ return Response.status(Response.Status.BAD_REQUEST)
74
+ .entity(Templates.profile(username, displayName, e.getMessage()))
75
+ .build();
76
+ }
77
+ }
78
+
46
79
@GET
47
80
@Path("/keys")
48
81
public TemplateInstance keys()
MODIFY
src/main/java/de/workaround/account/UserProvisioningService.java
+30 -0
@@ -33,6 +33,36 @@
33
33
return user;
34
34
}
35
35
36
+ /**
37
+ * Provisioning for an interactive OIDC login. Unlike {@link #provision}, the handle is NOT derived
38
+ * from the {@code preferred_username} claim: a new user is created without a username and chooses a
39
+ * URL-safe one on the onboarding page. The claim is only used for the dev {@code adopt-username}
40
+ * path, which re-keys a seeded same-username row to the fresh subject.
41
+ */
42
+ @Transactional
43
+ public User provisionFromOidc(String oidcSub, String claimUsername, String displayName, String email)
44
+ {
45
+ User user = users.findByOidcSubOptional(oidcSub).orElseGet(() -> {
46
+ if (adoptExistingUsername && claimUsername != null)
47
+ {
48
+ User existing = users.findByUsername(claimUsername).orElse(null);
49
+ if (existing != null)
50
+ {
51
+ existing.oidcSub = oidcSub;
52
+ return existing;
53
+ }
54
+ }
55
+ User created = new User();
56
+ created.oidcSub = oidcSub;
57
+ // username stays null: onboarding required (see OnboardingFilter)
58
+ created.persist();
59
+ return created;
60
+ });
61
+ user.displayName = displayName;
62
+ user.email = email;
63
+ return user;
64
+ }
65
+
36
66
private User provisionMissing(String oidcSub, String username)
37
67
{
38
68
if (adoptExistingUsername)
ADD
src/main/java/de/workaround/account/UsernameService.java
+55 -0
@@ -0,0 +1,55 @@
1
+package de.workaround.account;
2
+
3
+import java.util.Optional;
4
+import java.util.regex.Pattern;
5
+
6
+import de.workaround.model.User;
7
+import jakarta.enterprise.context.ApplicationScoped;
8
+import jakarta.inject.Inject;
9
+import jakarta.transaction.Transactional;
10
+
11
+/**
12
+ * Validates and assigns a user's URL-safe handle. Used both during onboarding and when an existing
13
+ * user renames in settings. The handle is the path segment in every repo/SSH/federation URL, so it
14
+ * must match a strict charset and be unique across users.
15
+ */
16
+@ApplicationScoped
17
+public class UsernameService
18
+{
19
+ public static final Pattern HANDLE = Pattern.compile("^[a-z0-9][a-z0-9-]{0,38}$");
20
+
21
+ @Inject
22
+ User.Repo users;
23
+
24
+ @Transactional
25
+ public void choose(User user, String handle)
26
+ {
27
+ String candidate = handle == null ? "" : handle.trim();
28
+ if (!HANDLE.matcher(candidate).matches())
29
+ {
30
+ throw new InvalidUsernameException(
31
+ "Username must be 1-39 characters, lowercase letters, digits or hyphens, and start with a letter or digit.");
32
+ }
33
+ Optional<User> existing = users.findByUsername(candidate);
34
+ if (existing.isPresent() && !existing.get().id.equals(user.id))
35
+ {
36
+ throw new UsernameTakenException("That username is already taken.");
37
+ }
38
+ User managed = users.findById(user.id);
39
+ managed.username = candidate;
40
+ user.username = candidate;
41
+ }
42
+
43
+ @Transactional
44
+ public void setDisplayName(User user, String displayName)
45
+ {
46
+ if (displayName == null || displayName.isBlank())
47
+ {
48
+ return;
49
+ }
50
+ User managed = users.findById(user.id);
51
+ managed.displayName = displayName;
52
+ user.displayName = displayName;
53
+ }
54
+
55
+}
ADD
src/main/java/de/workaround/account/UsernameTakenException.java
+10 -0
@@ -0,0 +1,10 @@
1
+package de.workaround.account;
2
+
3
+public class UsernameTakenException extends RuntimeException
4
+{
5
+ public UsernameTakenException(String message)
6
+ {
7
+ super(message);
8
+ }
9
+
10
+}
MODIFY
src/main/java/de/workaround/model/User.java
+2 -0
@@ -26,6 +26,8 @@
26
26
27
27
public String oidcSub;
28
28
29
+ // URL-safe handle chosen by the user during onboarding. Null/blank means onboarding is still
30
+ // pending: the user has logged in but not yet picked a handle (see OnboardingFilter).
29
31
public String username;
30
32
31
33
public String displayName;
MODIFY
src/main/resources/application.properties
+1 -1
@@ -49,7 +49,7 @@
49
49
%dev.gitshark.dev.adopt-username=true
50
50
51
51
# Pages that require a logged-in user (git transport does its own auth)
52
-quarkus.http.auth.permission.authenticated.paths=/settings/*,/repos/new,/login
52
+quarkus.http.auth.permission.authenticated.paths=/settings/*,/repos/new,/login,/onboarding
53
53
quarkus.http.auth.permission.authenticated.policy=authenticated
54
54
55
55
# Native image: JGit holds static Random/lazy state that must not be build-time initialized
ADD
src/main/resources/db/migration/V4__username_nullable.sql
+5 -0
@@ -0,0 +1,5 @@
1
+-- A null username marks an onboarding-pending account: on first OIDC login the user record is
2
+-- created without a handle and the user chooses a URL-safe one on the onboarding page. The unique
3
+-- constraint stays (Postgres allows multiple NULLs, so many onboarding-pending accounts coexist).
4
+alter table users
5
+ alter column username drop not null;
ADD
src/main/resources/templates/OnboardingResource/onboarding.html
+17 -0
@@ -0,0 +1,17 @@
1
+{#include layout}
2
+{#title}Choose your username – git-shark{/title}
3
+{#nav}{/nav}
4
+<h1>Welcome to git-shark</h1>
5
+<p>Pick a username. It is used in your repository, clone and federation URLs, so it must be URL-safe:
6
+ lowercase letters, digits and hyphens, starting with a letter or digit.</p>
7
+{#if error}
8
+<p class="error">{error}</p>
9
+{/if}
10
+<form method="post" action="/onboarding">
11
+ <p><label>Username
12
+ <input class="mono" name="username" required pattern="[a-z0-9][a-z0-9-]*" maxlength="39"
13
+ value="{suggestedHandle}" autofocus></label></p>
14
+ <p><label>Display name <input name="displayName" value="{displayName ?: ''}"></label></p>
15
+ <button class="btn btn-primary">Continue</button>
16
+</form>
17
+{/include}
ADD
src/main/resources/templates/SettingsResource/profile.html
+14 -0
@@ -0,0 +1,14 @@
1
+{#include layout}
2
+{#title}Profile – git-shark{/title}
3
+<h1>Profile</h1>
4
+{#if error}
5
+<p class="error">{error}</p>
6
+{/if}
7
+<form method="post" action="/settings/profile">
8
+ <p><label>Username
9
+ <input class="mono" name="username" required pattern="[a-z0-9][a-z0-9-]*" maxlength="39"
10
+ value="{username ?: ''}"></label></p>
11
+ <p><label>Display name <input name="displayName" value="{displayName ?: ''}"></label></p>
12
+ <button class="btn btn-primary">Save</button>
13
+</form>
14
+{/include}
MODIFY
src/main/resources/templates/layout.html
+1 -0
@@ -16,6 +16,7 @@
16
16
<a class="brand" href="/"><svg class="bmark" viewBox="0 0 64 64" width="26" height="26" aria-hidden="true"><rect width="64" height="64" rx="15" fill="#168b85"></rect><path d="M12 42 H52 M24 42 C29 42 31 50 38 50" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"></path><circle cx="16" cy="42" r="3.2" fill="#fff"></circle><circle cx="38" cy="50" r="3.2" fill="#fff"></circle><path d="M26 42 C28 32 32 24 39 14 C40 22 42 33 45 42 Z" fill="#fff"></path></svg><span class="g">git</span><span class="s">shark</span></a>
17
17
<nav>
18
18
{#insert nav}
19
+ <a href="/settings/profile">Profile</a>
19
20
<a href="/settings/keys">SSH keys</a>
20
21
<a href="/settings/tokens">Access tokens</a>
21
22
<form class="logout" method="post" action="/logout"><button type="submit">Logout</button></form>
ADD
src/test/java/de/workaround/account/HandleSuggesterTest.java
+48 -0
@@ -0,0 +1,48 @@
1
+package de.workaround.account;
2
+
3
+import org.junit.jupiter.api.Test;
4
+
5
+import static org.junit.jupiter.api.Assertions.assertEquals;
6
+import static org.junit.jupiter.api.Assertions.assertTrue;
7
+
8
+class HandleSuggesterTest
9
+{
10
+ @Test
11
+ void stripsSpnDomain()
12
+ {
13
+ assertEquals("miggi", HandleSuggester.suggest("miggi@sso.mymiggi.de"));
14
+ }
15
+
16
+ @Test
17
+ void lowercasesAndReplacesInvalidChars()
18
+ {
19
+ assertEquals("john-doe", HandleSuggester.suggest("John.Doe"));
20
+ }
21
+
22
+ @Test
23
+ void trimsLeadingAndTrailingSeparators()
24
+ {
25
+ assertEquals("foo", HandleSuggester.suggest("__foo__"));
26
+ }
27
+
28
+ @Test
29
+ void clampsToMaxLength()
30
+ {
31
+ String suggestion = HandleSuggester.suggest("a".repeat(80));
32
+ assertTrue(suggestion.length() <= 39, "suggestion must fit the handle length limit");
33
+ }
34
+
35
+ @Test
36
+ void returnsValidHandleMatchingPattern()
37
+ {
38
+ String suggestion = HandleSuggester.suggest("Miggi@sso.mymiggi.de");
39
+ assertTrue(suggestion.matches("^[a-z0-9][a-z0-9-]{0,38}$"), "suggestion must satisfy the handle pattern");
40
+ }
41
+
42
+ @Test
43
+ void blankClaimYieldsBlankSuggestion()
44
+ {
45
+ assertEquals("", HandleSuggester.suggest(null));
46
+ assertEquals("", HandleSuggester.suggest(" "));
47
+ }
48
+}
ADD
src/test/java/de/workaround/account/OnboardingFlowTest.java
+108 -0
@@ -0,0 +1,108 @@
1
+package de.workaround.account;
2
+
3
+import org.junit.jupiter.api.Test;
4
+
5
+import de.workaround.model.User;
6
+import io.quarkus.test.junit.QuarkusTest;
7
+import io.quarkus.test.security.TestSecurity;
8
+import io.restassured.http.ContentType;
9
+import jakarta.inject.Inject;
10
+import jakarta.transaction.Transactional;
11
+
12
+import static io.restassured.RestAssured.given;
13
+import static org.hamcrest.CoreMatchers.containsString;
14
+import static org.junit.jupiter.api.Assertions.assertEquals;
15
+
16
+@QuarkusTest
17
+class OnboardingFlowTest
18
+{
19
+ @Inject
20
+ User.Repo users;
21
+
22
+ @Test
23
+ @TestSecurity(user = "flow-valid")
24
+ void validHandleIsStoredAndRedirectsHome()
25
+ {
26
+ seedOnboardingPending("flow-valid");
27
+
28
+ given().redirects().follow(false)
29
+ .contentType(ContentType.URLENC)
30
+ .formParam("username", "flow-handle")
31
+ .formParam("displayName", "Flow User")
32
+ .when().post("/onboarding")
33
+ .then().statusCode(303).header("Location", containsString("/"));
34
+
35
+ assertEquals("flow-handle", findBySub("flow-valid").username);
36
+ assertEquals("Flow User", findBySub("flow-valid").displayName);
37
+ }
38
+
39
+ @Test
40
+ @TestSecurity(user = "flow-invalid")
41
+ void invalidHandleIsRejected()
42
+ {
43
+ seedOnboardingPending("flow-invalid");
44
+
45
+ given().redirects().follow(false)
46
+ .contentType(ContentType.URLENC)
47
+ .formParam("username", "Bad.Handle")
48
+ .formParam("displayName", "X")
49
+ .when().post("/onboarding")
50
+ .then().statusCode(400);
51
+ }
52
+
53
+ @Test
54
+ @TestSecurity(user = "flow-taken")
55
+ void takenHandleIsRejected()
56
+ {
57
+ seedOnboardingPending("flow-taken");
58
+ seedWithHandle("flow-taken-owner", "flow-taken-handle");
59
+
60
+ given().redirects().follow(false)
61
+ .contentType(ContentType.URLENC)
62
+ .formParam("username", "flow-taken-handle")
63
+ .formParam("displayName", "X")
64
+ .when().post("/onboarding")
65
+ .then().statusCode(400);
66
+ }
67
+
68
+ @Test
69
+ @TestSecurity(user = "flow-already")
70
+ void onboardedUserVisitingOnboardingIsRedirectedHome()
71
+ {
72
+ seedWithHandle("flow-already", "flow-already");
73
+
74
+ given().redirects().follow(false)
75
+ .when().get("/onboarding")
76
+ .then().statusCode(303).header("Location", containsString("/"));
77
+ }
78
+
79
+ private User findBySub(String sub)
80
+ {
81
+ return users.findByOidcSub(sub);
82
+ }
83
+
84
+ @Transactional
85
+ void seedOnboardingPending(String sub)
86
+ {
87
+ if (users.findByOidcSubOptional(sub).isPresent())
88
+ {
89
+ return;
90
+ }
91
+ User user = new User();
92
+ user.oidcSub = sub;
93
+ user.persist();
94
+ }
95
+
96
+ @Transactional
97
+ void seedWithHandle(String sub, String handle)
98
+ {
99
+ if (users.findByOidcSubOptional(sub).isPresent())
100
+ {
101
+ return;
102
+ }
103
+ User user = new User();
104
+ user.oidcSub = sub;
105
+ user.username = handle;
106
+ user.persist();
107
+ }
108
+}
ADD
src/test/java/de/workaround/account/OnboardingRedirectTest.java
+97 -0
@@ -0,0 +1,97 @@
1
+package de.workaround.account;
2
+
3
+import org.junit.jupiter.api.Test;
4
+
5
+import de.workaround.model.User;
6
+import io.quarkus.test.junit.QuarkusTest;
7
+import io.quarkus.test.security.TestSecurity;
8
+import jakarta.inject.Inject;
9
+import jakarta.transaction.Transactional;
10
+
11
+import static io.restassured.RestAssured.given;
12
+import static org.hamcrest.CoreMatchers.containsString;
13
+import static org.hamcrest.CoreMatchers.equalTo;
14
+
15
+@QuarkusTest
16
+class OnboardingRedirectTest
17
+{
18
+ @Inject
19
+ User.Repo users;
20
+
21
+ @Test
22
+ @TestSecurity(user = "needs-onboard")
23
+ void handleLessUserIsRedirectedFromHome()
24
+ {
25
+ seedOnboardingPending("needs-onboard");
26
+
27
+ given().redirects().follow(false)
28
+ .when().get("/")
29
+ .then().statusCode(303).header("Location", containsString("/onboarding"));
30
+ }
31
+
32
+ @Test
33
+ @TestSecurity(user = "needs-onboard-settings")
34
+ void handleLessUserIsRedirectedFromProtectedPage()
35
+ {
36
+ seedOnboardingPending("needs-onboard-settings");
37
+
38
+ given().redirects().follow(false)
39
+ .when().get("/settings/keys")
40
+ .then().statusCode(303).header("Location", containsString("/onboarding"));
41
+ }
42
+
43
+ @Test
44
+ @TestSecurity(user = "onboarded-user")
45
+ void onboardedUserIsNotRedirected()
46
+ {
47
+ seedWithHandle("onboarded-user", "onboarded-user");
48
+
49
+ given().redirects().follow(false)
50
+ .when().get("/")
51
+ .then().statusCode(200);
52
+ }
53
+
54
+ @Test
55
+ @TestSecurity(user = "needs-onboard-page")
56
+ void onboardingPageItselfIsReachable()
57
+ {
58
+ seedOnboardingPending("needs-onboard-page");
59
+
60
+ given().redirects().follow(false)
61
+ .when().get("/onboarding")
62
+ .then().statusCode(200).body(containsString("username"));
63
+ }
64
+
65
+ @Test
66
+ void anonymousIsNotRedirected()
67
+ {
68
+ given().redirects().follow(false)
69
+ .when().get("/")
70
+ .then().statusCode(equalTo(200));
71
+ }
72
+
73
+ @Transactional
74
+ void seedOnboardingPending(String sub)
75
+ {
76
+ if (users.findByOidcSubOptional(sub).isPresent())
77
+ {
78
+ return;
79
+ }
80
+ User user = new User();
81
+ user.oidcSub = sub;
82
+ user.persist();
83
+ }
84
+
85
+ @Transactional
86
+ void seedWithHandle(String sub, String handle)
87
+ {
88
+ if (users.findByOidcSubOptional(sub).isPresent())
89
+ {
90
+ return;
91
+ }
92
+ User user = new User();
93
+ user.oidcSub = sub;
94
+ user.username = handle;
95
+ user.persist();
96
+ }
97
+}
ADD
src/test/java/de/workaround/account/SettingsProfileTest.java
+81 -0
@@ -0,0 +1,81 @@
1
+package de.workaround.account;
2
+
3
+import org.junit.jupiter.api.Test;
4
+
5
+import de.workaround.model.User;
6
+import io.quarkus.test.junit.QuarkusTest;
7
+import io.quarkus.test.security.TestSecurity;
8
+import io.restassured.http.ContentType;
9
+import jakarta.inject.Inject;
10
+import jakarta.transaction.Transactional;
11
+
12
+import static io.restassured.RestAssured.given;
13
+import static org.hamcrest.CoreMatchers.containsString;
14
+import static org.hamcrest.Matchers.anyOf;
15
+import static org.hamcrest.Matchers.is;
16
+import static org.junit.jupiter.api.Assertions.assertEquals;
17
+
18
+@QuarkusTest
19
+class SettingsProfileTest
20
+{
21
+ @Inject
22
+ User.Repo users;
23
+
24
+ @Test
25
+ @TestSecurity(user = "profile-view")
26
+ void profilePageShowsCurrentHandle()
27
+ {
28
+ given()
29
+ .when().get("/settings/profile")
30
+ .then().statusCode(200).body(containsString("profile-view"));
31
+ }
32
+
33
+ @Test
34
+ @TestSecurity(user = "profile-rename")
35
+ void renamesHandle()
36
+ {
37
+ given().redirects().follow(false)
38
+ .contentType(ContentType.URLENC)
39
+ .formParam("username", "renamed-handle")
40
+ .formParam("displayName", "Renamed")
41
+ .when().post("/settings/profile")
42
+ .then().statusCode(anyOf(is(302), is(303)));
43
+
44
+ assertEquals("renamed-handle", bySub("profile-rename").username);
45
+ assertEquals("Renamed", bySub("profile-rename").displayName);
46
+ }
47
+
48
+ @Test
49
+ @TestSecurity(user = "profile-collide")
50
+ void rejectsRenameToTakenHandle()
51
+ {
52
+ seedWithHandle("profile-collide-owner", "profile-collide-taken");
53
+
54
+ given().redirects().follow(false)
55
+ .contentType(ContentType.URLENC)
56
+ .formParam("username", "profile-collide-taken")
57
+ .formParam("displayName", "X")
58
+ .when().post("/settings/profile")
59
+ .then().statusCode(400);
60
+
61
+ assertEquals("profile-collide", bySub("profile-collide").username, "handle unchanged after collision");
62
+ }
63
+
64
+ private User bySub(String sub)
65
+ {
66
+ return users.findByOidcSub(sub);
67
+ }
68
+
69
+ @Transactional
70
+ void seedWithHandle(String sub, String handle)
71
+ {
72
+ if (users.findByOidcSubOptional(sub).isPresent())
73
+ {
74
+ return;
75
+ }
76
+ User user = new User();
77
+ user.oidcSub = sub;
78
+ user.username = handle;
79
+ user.persist();
80
+ }
81
+}
ADD
src/test/java/de/workaround/account/UserProvisioningOnboardingTest.java
+52 -0
@@ -0,0 +1,52 @@
1
+package de.workaround.account;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.model.User;
8
+import io.quarkus.test.TestTransaction;
9
+import io.quarkus.test.junit.QuarkusTest;
10
+import jakarta.inject.Inject;
11
+
12
+import static org.junit.jupiter.api.Assertions.assertEquals;
13
+import static org.junit.jupiter.api.Assertions.assertNull;
14
+
15
+@QuarkusTest
16
+class UserProvisioningOnboardingTest
17
+{
18
+ @Inject
19
+ UserProvisioningService service;
20
+
21
+ @Inject
22
+ User.Repo users;
23
+
24
+ @Test
25
+ @TestTransaction
26
+ void oidcFirstLoginCreatesUserWithoutHandle()
27
+ {
28
+ String sub = "sub-" + UUID.randomUUID();
29
+
30
+ User user = service.provisionFromOidc(sub, "miggi@sso.mymiggi.de", "Miggi", "miggi@example.com");
31
+
32
+ assertNull(user.username, "handle must not be derived from the claim; onboarding chooses it");
33
+ User stored = users.findByOidcSub(sub);
34
+ assertNull(stored.username);
35
+ assertEquals("Miggi", stored.displayName);
36
+ assertEquals("miggi@example.com", stored.email);
37
+ }
38
+
39
+ @Test
40
+ @TestTransaction
41
+ void returningUserKeepsChosenHandle()
42
+ {
43
+ String sub = "sub-" + UUID.randomUUID();
44
+ User created = service.provisionFromOidc(sub, "claim", "Name", "e@example.com");
45
+ created.username = "chosen-handle";
46
+
47
+ User again = service.provisionFromOidc(sub, "claim", "New Name", "new@example.com");
48
+
49
+ assertEquals("chosen-handle", again.username, "chosen handle survives re-login");
50
+ assertEquals("New Name", again.displayName);
51
+ }
52
+}
ADD
src/test/java/de/workaround/account/UsernameServiceTest.java
+79 -0
@@ -0,0 +1,79 @@
1
+package de.workaround.account;
2
+
3
+import java.util.UUID;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.model.User;
8
+import io.quarkus.test.TestTransaction;
9
+import io.quarkus.test.junit.QuarkusTest;
10
+import jakarta.inject.Inject;
11
+
12
+import static org.junit.jupiter.api.Assertions.assertEquals;
13
+import static org.junit.jupiter.api.Assertions.assertThrows;
14
+
15
+@QuarkusTest
16
+class UsernameServiceTest
17
+{
18
+ @Inject
19
+ UsernameService usernames;
20
+
21
+ @Inject
22
+ User.Repo users;
23
+
24
+ private User onboardingPendingUser()
25
+ {
26
+ User user = new User();
27
+ user.oidcSub = "sub-" + UUID.randomUUID();
28
+ user.persist();
29
+ return user;
30
+ }
31
+
32
+ @Test
33
+ @TestTransaction
34
+ void acceptsValidUniqueHandle()
35
+ {
36
+ User user = onboardingPendingUser();
37
+ String handle = "handle-" + UUID.randomUUID().toString().substring(0, 8);
38
+
39
+ usernames.choose(user, handle);
40
+
41
+ assertEquals(handle, users.findById(user.id).username);
42
+ }
43
+
44
+ @Test
45
+ @TestTransaction
46
+ void rejectsInvalidCharset()
47
+ {
48
+ User user = onboardingPendingUser();
49
+ assertThrows(InvalidUsernameException.class, () -> usernames.choose(user, "Bad.Name"));
50
+ }
51
+
52
+ @Test
53
+ @TestTransaction
54
+ void rejectsLeadingHyphen()
55
+ {
56
+ User user = onboardingPendingUser();
57
+ assertThrows(InvalidUsernameException.class, () -> usernames.choose(user, "-nope"));
58
+ }
59
+
60
+ @Test
61
+ @TestTransaction
62
+ void rejectsTooLongHandle()
63
+ {
64
+ User user = onboardingPendingUser();
65
+ assertThrows(InvalidUsernameException.class, () -> usernames.choose(user, "a".repeat(40)));
66
+ }
67
+
68
+ @Test
69
+ @TestTransaction
70
+ void rejectsTakenHandle()
71
+ {
72
+ String taken = "taken-" + UUID.randomUUID().toString().substring(0, 8);
73
+ User existing = onboardingPendingUser();
74
+ usernames.choose(existing, taken);
75
+
76
+ User newcomer = onboardingPendingUser();
77
+ assertThrows(UsernameTakenException.class, () -> usernames.choose(newcomer, taken));
78
+ }
79
+}