gitshark

Clone repository

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

← Commits

✨ (account): Add first-login onboarding to choose a URL-safe username

e2c892e9c315c525f0cc7fdb5392ae84cb684f7e · Miggi · 2026-06-27T19:37:50Z

Changes

27 files changed, +1059 -17

MODIFY README.md +2 -2
diff --git a/README.md b/README.md
index 5888382..1261afc 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/openspec/changes/add-user-onboarding/.openspec.yaml b/openspec/changes/add-user-onboarding/.openspec.yaml
new file mode 100644
index 0000000..f9be753
--- /dev/null
+++ b/openspec/changes/add-user-onboarding/.openspec.yaml
@@ -0,0 +1,2 @@
1 +schema: spec-driven
2 +created: 2026-06-27
ADD openspec/changes/add-user-onboarding/design.md +58 -0
diff --git a/openspec/changes/add-user-onboarding/design.md b/openspec/changes/add-user-onboarding/design.md
new file mode 100644
index 0000000..3779774
--- /dev/null
+++ b/openspec/changes/add-user-onboarding/design.md
@@ -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
diff --git a/openspec/changes/add-user-onboarding/proposal.md b/openspec/changes/add-user-onboarding/proposal.md
new file mode 100644
index 0000000..d2faf81
--- /dev/null
+++ b/openspec/changes/add-user-onboarding/proposal.md
@@ -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
diff --git a/openspec/changes/add-user-onboarding/specs/user-onboarding/spec.md b/openspec/changes/add-user-onboarding/specs/user-onboarding/spec.md
new file mode 100644
index 0000000..cf32137
--- /dev/null
+++ b/openspec/changes/add-user-onboarding/specs/user-onboarding/spec.md
@@ -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
diff --git a/openspec/changes/add-user-onboarding/tasks.md b/openspec/changes/add-user-onboarding/tasks.md
new file mode 100644
index 0000000..2dea421
--- /dev/null
+++ b/openspec/changes/add-user-onboarding/tasks.md
@@ -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
diff --git a/src/main/java/de/workaround/account/CurrentUser.java b/src/main/java/de/workaround/account/CurrentUser.java
index 784be98..400e930 100644
--- a/src/main/java/de/workaround/account/CurrentUser.java
+++ b/src/main/java/de/workaround/account/CurrentUser.java
@@ -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
diff --git a/src/main/java/de/workaround/account/HandleSuggester.java b/src/main/java/de/workaround/account/HandleSuggester.java
new file mode 100644
index 0000000..ba85f04
--- /dev/null
+++ b/src/main/java/de/workaround/account/HandleSuggester.java
@@ -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
diff --git a/src/main/java/de/workaround/account/InvalidUsernameException.java b/src/main/java/de/workaround/account/InvalidUsernameException.java
new file mode 100644
index 0000000..aa1bb6b
--- /dev/null
+++ b/src/main/java/de/workaround/account/InvalidUsernameException.java
@@ -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
diff --git a/src/main/java/de/workaround/account/OnboardingFilter.java b/src/main/java/de/workaround/account/OnboardingFilter.java
new file mode 100644
index 0000000..5c2dfec
--- /dev/null
+++ b/src/main/java/de/workaround/account/OnboardingFilter.java
@@ -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
diff --git a/src/main/java/de/workaround/account/OnboardingResource.java b/src/main/java/de/workaround/account/OnboardingResource.java
new file mode 100644
index 0000000..c95a0d2
--- /dev/null
+++ b/src/main/java/de/workaround/account/OnboardingResource.java
@@ -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
diff --git a/src/main/java/de/workaround/account/SettingsResource.java b/src/main/java/de/workaround/account/SettingsResource.java
index b830a6a..b2439bd 100644
--- a/src/main/java/de/workaround/account/SettingsResource.java
+++ b/src/main/java/de/workaround/account/SettingsResource.java
@@ -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
diff --git a/src/main/java/de/workaround/account/UserProvisioningService.java b/src/main/java/de/workaround/account/UserProvisioningService.java
index 04ad000..b115080 100644
--- a/src/main/java/de/workaround/account/UserProvisioningService.java
+++ b/src/main/java/de/workaround/account/UserProvisioningService.java
@@ -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
diff --git a/src/main/java/de/workaround/account/UsernameService.java b/src/main/java/de/workaround/account/UsernameService.java
new file mode 100644
index 0000000..7490652
--- /dev/null
+++ b/src/main/java/de/workaround/account/UsernameService.java
@@ -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
diff --git a/src/main/java/de/workaround/account/UsernameTakenException.java b/src/main/java/de/workaround/account/UsernameTakenException.java
new file mode 100644
index 0000000..b6c6a1a
--- /dev/null
+++ b/src/main/java/de/workaround/account/UsernameTakenException.java
@@ -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
diff --git a/src/main/java/de/workaround/model/User.java b/src/main/java/de/workaround/model/User.java
index cf435f4..6d8395e 100644
--- a/src/main/java/de/workaround/model/User.java
+++ b/src/main/java/de/workaround/model/User.java
@@ -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
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 02df5eb..fcc5ad2 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -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
diff --git a/src/main/resources/db/migration/V4__username_nullable.sql b/src/main/resources/db/migration/V4__username_nullable.sql
new file mode 100644
index 0000000..8e6b1f5
--- /dev/null
+++ b/src/main/resources/db/migration/V4__username_nullable.sql
@@ -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
diff --git a/src/main/resources/templates/OnboardingResource/onboarding.html b/src/main/resources/templates/OnboardingResource/onboarding.html
new file mode 100644
index 0000000..f679047
--- /dev/null
+++ b/src/main/resources/templates/OnboardingResource/onboarding.html
@@ -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
diff --git a/src/main/resources/templates/SettingsResource/profile.html b/src/main/resources/templates/SettingsResource/profile.html
new file mode 100644
index 0000000..50ec8a4
--- /dev/null
+++ b/src/main/resources/templates/SettingsResource/profile.html
@@ -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
diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html
index 5167798..97241ba 100644
--- a/src/main/resources/templates/layout.html
+++ b/src/main/resources/templates/layout.html
@@ -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
diff --git a/src/test/java/de/workaround/account/HandleSuggesterTest.java b/src/test/java/de/workaround/account/HandleSuggesterTest.java
new file mode 100644
index 0000000..d6741a2
--- /dev/null
+++ b/src/test/java/de/workaround/account/HandleSuggesterTest.java
@@ -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
diff --git a/src/test/java/de/workaround/account/OnboardingFlowTest.java b/src/test/java/de/workaround/account/OnboardingFlowTest.java
new file mode 100644
index 0000000..e565c37
--- /dev/null
+++ b/src/test/java/de/workaround/account/OnboardingFlowTest.java
@@ -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
diff --git a/src/test/java/de/workaround/account/OnboardingRedirectTest.java b/src/test/java/de/workaround/account/OnboardingRedirectTest.java
new file mode 100644
index 0000000..97c2153
--- /dev/null
+++ b/src/test/java/de/workaround/account/OnboardingRedirectTest.java
@@ -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
diff --git a/src/test/java/de/workaround/account/SettingsProfileTest.java b/src/test/java/de/workaround/account/SettingsProfileTest.java
new file mode 100644
index 0000000..243aaea
--- /dev/null
+++ b/src/test/java/de/workaround/account/SettingsProfileTest.java
@@ -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
diff --git a/src/test/java/de/workaround/account/UserProvisioningOnboardingTest.java b/src/test/java/de/workaround/account/UserProvisioningOnboardingTest.java
new file mode 100644
index 0000000..23ef568
--- /dev/null
+++ b/src/test/java/de/workaround/account/UserProvisioningOnboardingTest.java
@@ -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
diff --git a/src/test/java/de/workaround/account/UsernameServiceTest.java b/src/test/java/de/workaround/account/UsernameServiceTest.java
new file mode 100644
index 0000000..ab66d84
--- /dev/null
+++ b/src/test/java/de/workaround/account/UsernameServiceTest.java
@@ -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 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog