✨ feat(dev): seed demo data for user alice on dev startup
Changes
6 files changed, +302 -7
MODIFY
README.md
+7 -0
@@ -86,6 +86,13 @@
86
86
Native build uses a container build automatically when no local GraalVM is present
87
87
(`-Dquarkus.native.container-build=true` to force). Binary: `target/git-shark-1.0-SNAPSHOT-runner`.
88
88
89
+### Dev mode seed data
90
+
91
+Two `%dev`-only flags are set in `application.properties` (both default `false` in all other profiles):
92
+
93
+- `gitshark.dev.seed-data=true` — on startup, `DevDataSeeder` idempotently creates user `alice` owning a public repository `demo` with one commit (`README.md`, "Initial commit"). A fresh dev instance is never empty.
94
+- `gitshark.dev.adopt-username=true` — Keycloak Dev Services mint a fresh OIDC subject on every run, so the seeded `alice` row would otherwise collide on login. With this flag, an unknown subject whose username matches an existing account re-keys that account to the new subject instead of rejecting the login. **Never enabled in production** (re-keying from a username claim is an account-takeover vector).
95
+
89
96
## Persisted data
90
97
91
98
| Store | What |
MODIFY
src/main/java/de/workaround/account/UserProvisioningService.java
+28 -7
@@ -4,6 +4,7 @@
4
4
import jakarta.enterprise.context.ApplicationScoped;
5
5
import jakarta.inject.Inject;
6
6
import jakarta.transaction.Transactional;
7
+import org.eclipse.microprofile.config.inject.ConfigProperty;
7
8
8
9
/**
9
10
* Creates or updates the local user record from OIDC token claims. The OIDC subject is the
@@ -15,19 +16,39 @@
15
16
@Inject
16
17
User.Repo users;
17
18
19
+ // Dev convenience only: Keycloak Dev Services mint a fresh OIDC subject for the seeded
20
+ // alice/bob users on every run, so a real login would never match a pre-seeded row by
21
+ // subject and would collide on the unique username. When enabled (%dev), an unknown subject
22
+ // adopts the existing same-username row instead of inserting a duplicate. Off in prod —
23
+ // trusting a username claim to re-key an account would be an account-takeover vector.
24
+ @ConfigProperty(name = "gitshark.dev.adopt-username", defaultValue = "false")
25
+ boolean adoptExistingUsername;
26
+
18
27
@Transactional
19
28
public User provision(String oidcSub, String username, String displayName, String email)
20
29
{
21
- User user = users.findByOidcSubOptional(oidcSub).orElseGet(() -> {
22
- User created = new User();
23
- created.oidcSub = oidcSub;
24
- created.username = username;
25
- created.persist();
26
- return created;
27
- });
30
+ User user = users.findByOidcSubOptional(oidcSub).orElseGet(() -> provisionMissing(oidcSub, username));
28
31
user.displayName = displayName;
29
32
user.email = email;
30
33
return user;
31
34
}
32
35
36
+ private User provisionMissing(String oidcSub, String username)
37
+ {
38
+ if (adoptExistingUsername)
39
+ {
40
+ User existing = users.findByUsername(username).orElse(null);
41
+ if (existing != null)
42
+ {
43
+ existing.oidcSub = oidcSub;
44
+ return existing;
45
+ }
46
+ }
47
+ User created = new User();
48
+ created.oidcSub = oidcSub;
49
+ created.username = username;
50
+ created.persist();
51
+ return created;
52
+ }
53
+
33
54
}
ADD
src/main/java/de/workaround/dev/DevDataSeeder.java
+146 -0
@@ -0,0 +1,146 @@
1
+package de.workaround.dev;
2
+
3
+import java.nio.file.Files;
4
+import java.nio.file.Path;
5
+
6
+import org.eclipse.jgit.api.Git;
7
+import org.eclipse.jgit.transport.RefSpec;
8
+import org.jboss.logging.Logger;
9
+
10
+import de.workaround.git.GitRepositoryService;
11
+import de.workaround.model.Repository;
12
+import de.workaround.model.User;
13
+import io.quarkus.runtime.StartupEvent;
14
+import jakarta.enterprise.context.ApplicationScoped;
15
+import jakarta.enterprise.event.Observes;
16
+import jakarta.inject.Inject;
17
+import jakarta.transaction.Transactional;
18
+import org.eclipse.microprofile.config.inject.ConfigProperty;
19
+
20
+/**
21
+ * Seeds a demo user (alice) owning a public "demo" repository with one commit, so a freshly
22
+ * started dev instance is not empty. Enabled only when {@code gitshark.dev.seed-data=true}
23
+ * (set under the {@code %dev} profile); idempotent, so restarts never duplicate data.
24
+ */
25
+@ApplicationScoped
26
+public class DevDataSeeder
27
+{
28
+ private static final Logger LOG = Logger.getLogger(DevDataSeeder.class);
29
+
30
+ private static final String DEMO_USER = "alice";
31
+ private static final String DEMO_REPO = "demo";
32
+
33
+ @Inject
34
+ GitRepositoryService repositories;
35
+
36
+ @Inject
37
+ User.Repo users;
38
+
39
+ @ConfigProperty(name = "gitshark.dev.seed-data", defaultValue = "false")
40
+ boolean enabled;
41
+
42
+ void onStart(@Observes StartupEvent event)
43
+ {
44
+ if (enabled)
45
+ {
46
+ seed();
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Ensures the demo data exists. Safe to call repeatedly: the user and repository are created
52
+ * only when absent, and the seed commit is written only when the repository has no commits.
53
+ */
54
+ @Transactional
55
+ public void seed()
56
+ {
57
+ User alice = users.findByUsername(DEMO_USER).orElseGet(() -> {
58
+ User created = new User();
59
+ created.oidcSub = "dev:" + DEMO_USER;
60
+ created.username = DEMO_USER;
61
+ created.displayName = "Alice (demo)";
62
+ created.email = DEMO_USER + "@demo.local";
63
+ created.persist();
64
+ return created;
65
+ });
66
+
67
+ Repository demo = repositories.find(alice.username, DEMO_REPO).orElse(null);
68
+ if (demo == null)
69
+ {
70
+ demo = repositories.create(alice, DEMO_REPO, Repository.Visibility.PUBLIC,
71
+ "Demo repository seeded for local development");
72
+ }
73
+
74
+ Path bare = repositories.repositoryPath(demo);
75
+ if (hasNoCommits(bare))
76
+ {
77
+ seedInitialCommit(bare);
78
+ LOG.infof("Seeded demo data: %s/%s with an initial commit", alice.username, demo.name);
79
+ }
80
+ }
81
+
82
+ private static boolean hasNoCommits(Path barePath)
83
+ {
84
+ try (Git git = Git.open(barePath.toFile()))
85
+ {
86
+ return git.getRepository().resolve("refs/heads/main") == null;
87
+ }
88
+ catch (Exception e)
89
+ {
90
+ throw new IllegalStateException("Failed to inspect demo repository at " + barePath, e);
91
+ }
92
+ }
93
+
94
+ private static void seedInitialCommit(Path barePath)
95
+ {
96
+ Path work = null;
97
+ try
98
+ {
99
+ work = Files.createTempDirectory("git-shark-seed");
100
+ try (Git git = Git.cloneRepository().setURI(barePath.toUri().toString()).setDirectory(work.toFile()).call())
101
+ {
102
+ Files.writeString(work.resolve("README.md"),
103
+ "# demo\n\nDemo repository seeded for local development.\n");
104
+ git.add().addFilepattern(".").call();
105
+ git.commit().setMessage("Initial commit").setSign(false)
106
+ .setAuthor(DEMO_USER, DEMO_USER + "@demo.local")
107
+ .setCommitter(DEMO_USER, DEMO_USER + "@demo.local").call();
108
+ git.push().setRefSpecs(new RefSpec("HEAD:refs/heads/main")).call();
109
+ }
110
+ }
111
+ catch (Exception e)
112
+ {
113
+ throw new IllegalStateException("Failed to seed initial commit into " + barePath, e);
114
+ }
115
+ finally
116
+ {
117
+ deleteRecursively(work);
118
+ }
119
+ }
120
+
121
+ private static void deleteRecursively(Path root)
122
+ {
123
+ if (root == null)
124
+ {
125
+ return;
126
+ }
127
+ try (var paths = Files.walk(root))
128
+ {
129
+ paths.sorted(java.util.Comparator.reverseOrder()).forEach(p -> {
130
+ try
131
+ {
132
+ Files.deleteIfExists(p);
133
+ }
134
+ catch (Exception ignored)
135
+ {
136
+ // best-effort cleanup of a temp working clone
137
+ }
138
+ });
139
+ }
140
+ catch (Exception ignored)
141
+ {
142
+ // best-effort cleanup
143
+ }
144
+ }
145
+
146
+}
MODIFY
src/main/resources/application.properties
+7 -0
@@ -23,6 +23,13 @@
23
23
quarkus.oidc.logout.path=/logout
24
24
quarkus.oidc.logout.post-logout-path=/
25
25
26
+# Dev-only convenience (see DevDataSeeder / UserProvisioningService):
27
+# - seed-data: on dev startup, create user alice + a public "demo" repo with one commit (idempotent).
28
+# - adopt-username: let a login adopt the seeded same-username row even though Keycloak Dev Services
29
+# mint a fresh OIDC subject each run. Both default false; never enable in production.
30
+%dev.gitshark.dev.seed-data=true
31
+%dev.gitshark.dev.adopt-username=true
32
+
26
33
# Pages that require a logged-in user (git transport does its own auth)
27
34
quarkus.http.auth.permission.authenticated.paths=/settings/*,/repos/new,/login
28
35
quarkus.http.auth.permission.authenticated.policy=authenticated
ADD
src/test/java/de/workaround/account/UserProvisioningAdoptTest.java
+59 -0
@@ -0,0 +1,59 @@
1
+package de.workaround.account;
2
+
3
+import java.util.Map;
4
+import java.util.UUID;
5
+
6
+import org.junit.jupiter.api.Test;
7
+
8
+import de.workaround.model.User;
9
+import io.quarkus.test.TestTransaction;
10
+import io.quarkus.test.junit.QuarkusTest;
11
+import io.quarkus.test.junit.QuarkusTestProfile;
12
+import io.quarkus.test.junit.TestProfile;
13
+import jakarta.inject.Inject;
14
+
15
+import static org.junit.jupiter.api.Assertions.assertEquals;
16
+
17
+/**
18
+ * Dev-only adoption: when the OIDC subject is unknown but a user with the same username already
19
+ * exists (e.g. a seeded dev user), provisioning adopts that row instead of creating a duplicate
20
+ * (which the unique username constraint would reject).
21
+ */
22
+@QuarkusTest
23
+@TestProfile(UserProvisioningAdoptTest.AdoptProfile.class)
24
+class UserProvisioningAdoptTest
25
+{
26
+ public static class AdoptProfile implements QuarkusTestProfile
27
+ {
28
+ @Override
29
+ public Map<String, String> getConfigOverrides()
30
+ {
31
+ return Map.of("gitshark.dev.adopt-username", "true");
32
+ }
33
+ }
34
+
35
+ @Inject
36
+ UserProvisioningService service;
37
+
38
+ @Inject
39
+ User.Repo users;
40
+
41
+ @Test
42
+ @TestTransaction
43
+ void adoptsExistingUsernameWhenSubjectUnknown()
44
+ {
45
+ String username = "adopt-" + UUID.randomUUID();
46
+ User seeded = new User();
47
+ seeded.oidcSub = "seed:" + username;
48
+ seeded.username = username;
49
+ seeded.persist();
50
+
51
+ String loginSub = "kc:" + UUID.randomUUID();
52
+ User logged = service.provision(loginSub, username, "Adopted", "adopt@example.com");
53
+
54
+ assertEquals(seeded.id, logged.id, "must adopt the existing row, not create a duplicate");
55
+ assertEquals(loginSub, users.findById(seeded.id).oidcSub, "adopted row takes the login subject");
56
+ assertEquals("Adopted", logged.displayName);
57
+ }
58
+
59
+}
ADD
src/test/java/de/workaround/dev/DevDataSeederTest.java
+55 -0
@@ -0,0 +1,55 @@
1
+package de.workaround.dev;
2
+
3
+import java.nio.file.Path;
4
+
5
+import org.junit.jupiter.api.Test;
6
+
7
+import de.workaround.git.GitBrowseService;
8
+import de.workaround.git.GitRepositoryService;
9
+import de.workaround.model.Repository;
10
+import io.quarkus.test.junit.QuarkusTest;
11
+import jakarta.inject.Inject;
12
+
13
+import static org.junit.jupiter.api.Assertions.assertEquals;
14
+import static org.junit.jupiter.api.Assertions.assertFalse;
15
+import static org.junit.jupiter.api.Assertions.assertTrue;
16
+
17
+@QuarkusTest
18
+class DevDataSeederTest
19
+{
20
+ @Inject
21
+ DevDataSeeder seeder;
22
+
23
+ @Inject
24
+ GitRepositoryService repositories;
25
+
26
+ @Inject
27
+ GitBrowseService browse;
28
+
29
+ @Test
30
+ void seedsAliceWithDemoRepoContainingACommit()
31
+ {
32
+ seeder.seed();
33
+
34
+ Repository demo = repositories.find("alice", "demo").orElseThrow(() -> new AssertionError("alice/demo missing"));
35
+ assertEquals(Repository.Visibility.PUBLIC, demo.visibility);
36
+
37
+ Path bare = repositories.repositoryPath(demo);
38
+ assertFalse(browse.isEmpty(bare), "demo repository must have commits");
39
+ assertTrue(browse.commitCount(bare, "main") >= 1, "demo repository must contain at least one commit");
40
+ }
41
+
42
+ @Test
43
+ void seedingIsIdempotent()
44
+ {
45
+ seeder.seed();
46
+ Path firstBare = repositories.repositoryPath(repositories.find("alice", "demo").orElseThrow());
47
+ int commitsAfterFirst = browse.commitCount(firstBare, "main");
48
+
49
+ seeder.seed();
50
+ Repository demo = repositories.find("alice", "demo").orElseThrow();
51
+ assertEquals(commitsAfterFirst, browse.commitCount(repositories.repositoryPath(demo), "main"),
52
+ "re-seeding must not add duplicate commits or repositories");
53
+ }
54
+
55
+}