Skip to content

Commit eb08081

Browse files
committedFeb 12, 2022
Add database storage for users
Add a database backend for storing users, to eventually replace the fake user base. It can store users and retrieve them by their username. Yet to integrate to the Concourse build and to provide instructions for setting up a local database environment in the readme file.
1 parent 6d2f881 commit eb08081

File tree

18 files changed

+361
-3
lines changed

18 files changed

+361
-3
lines changed
 

‎buildSrc/src/main/kotlin/libs.kt

+6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
object libs {
22
val faker = "io.github.serpro69:kotlin-faker:1.7.1"
33

4+
val flyway = "org.flywaydb:flyway-core:8.4.2"
5+
46
object hamcrest {
57
val library = "org.hamcrest:hamcrest:2.2"
68
}
@@ -15,12 +17,16 @@ object libs {
1517

1618
val juniversalchardet = "com.googlecode.juniversalchardet:juniversalchardet:1.0.3"
1719

20+
val kabinet = "com.vtence.kabinet:kabinet:0.2.4"
21+
1822
val konfig = "com.natpryce:konfig:1.6.10.0"
1923

2024
val mario = "com.vtence.mario:mario:0.3.3"
2125

2226
val molecule = "com.vtence.molecule:molecule:0.15.0-SNAPSHOT"
2327

28+
val postgres = "org.postgresql:postgresql:42.3.1"
29+
2430
val skrapeit = "it.skrape:skrapeit:1.1.1"
2531

2632
object selenium {

‎domain/src/main/kotlin/Transactor.kt

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package kickstart
2+
3+
4+
typealias UnitOfWork<T> = () -> T
5+
6+
interface Transactor {
7+
operator fun <T> invoke(work: UnitOfWork<T>): T
8+
}

‎domain/src/main/kotlin/security/User.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ value class Username(val value: String): Serializable {
99

1010
class User(val username: Username, private val password: PasswordHash) {
1111

12+
val passwordHash: String by password::value
13+
1214
fun checkPassword(secret: String) = password.validate(secret)
1315

1416
companion object {
15-
operator fun invoke(username: String) = User(Username(username), PasswordHash.create("secret"))
17+
operator fun invoke(username: String, password: String) = User(Username(username), PasswordHash.create(password))
1618
}
1719
}
1820

‎domain/src/testkit/kotlin/Builder.kt

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package kickstart;
2+
3+
4+
interface Builder<out T : Any> {
5+
fun build(): T
6+
}
7+
8+
fun <T : Any> Iterable<Builder<T>>.build(): Collection<T> = map { it.build() }
9+
10+
fun <T : Any> build(vararg items: Builder<T>): Collection<T> = items.map { it.build() }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package security
2+
3+
import kickstart.Builder
4+
import kickstart.security.User
5+
6+
class UserBuilder(
7+
var username: String = "john.doe",
8+
var password: String = "secret"
9+
): Builder<User> {
10+
override fun build(): User {
11+
return User(username, password)
12+
}
13+
}
14+
15+
fun user(build: UserBuilder.() -> Unit) = UserBuilder().apply(build)
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
package security
22

33
import com.natpryce.hamkrest.Matcher
4+
import com.natpryce.hamkrest.and
45
import com.natpryce.hamkrest.equalTo
56
import com.natpryce.hamkrest.has
67
import kickstart.security.User
78
import kickstart.security.Username
89

910
object UserThat {
1011

11-
fun hasUsername(username: String) = hasUsername(equalTo(Username(username)))
12+
fun hasSameStateAs(other: User): Matcher<User> {
13+
return hasUsername(other.username) and
14+
hasPasswordHash(other.passwordHash)
15+
}
16+
17+
fun hasUsername(username: String) = hasUsername(Username(username))
18+
19+
fun hasUsername(username: Username) = hasUsername(equalTo(username))
1220

1321
fun hasUsername(matching: Matcher<Username>) = has(User::username, matching)
22+
23+
fun hasPasswordHash(hash: String) = hasPasswordHash(equalTo(hash))
24+
25+
fun hasPasswordHash(matching: Matcher<String>) = has(User::passwordHash, matching)
1426
}

‎integration/build.gradle.kts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
plugins {
2+
id("kickstart")
3+
id("org.flywaydb.flyway") version "8.4.3"
4+
}
5+
6+
dependencies {
7+
implementation(project(":domain"))
8+
implementation(libs.kabinet)
9+
runtimeOnly(libs.postgres)
10+
11+
testkitImplementation(project(path = ":domain", configuration = "testkit"))
12+
testkitImplementation(libs.hamkrest)
13+
testkitImplementation(libs.flyway)
14+
}
15+
16+
tasks.jar {
17+
isPreserveFileTimestamps = false
18+
isReproducibleFileOrder = true
19+
}
+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package kickstart.db
2+
3+
import kickstart.Transactor
4+
import kickstart.UnitOfWork
5+
import java.sql.Connection
6+
7+
class JdbcTransactor(private val connection: Connection) : Transactor {
8+
9+
override fun <T> invoke(work: UnitOfWork<T>): T {
10+
try {
11+
return work().also { connection.commit() }
12+
} catch (e: Throwable) {
13+
connection.rollback()
14+
throw e
15+
}
16+
}
17+
}
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package kickstart.db
2+
3+
4+
import com.vtence.kabinet.Expression
5+
import com.vtence.kabinet.SqlBuilder
6+
import com.vtence.kabinet.append
7+
8+
9+
class IsNull(private val expression: Expression) : Expression {
10+
override fun build(statement: SqlBuilder) = statement {
11+
+expression
12+
+" IS NULL"
13+
}
14+
}
15+
16+
val Expression.isNull: Expression get() = IsNull(this)
17+
18+
19+
abstract class Comparison(val left: Expression, val right: Expression, val symbol: String) : Expression {
20+
override fun build(statement: SqlBuilder) = statement {
21+
append(left, " $symbol ", right)
22+
}
23+
}
24+
25+
26+
class Eq(left: Expression, right: Expression) : Comparison(left, right, "="), Expression
27+
28+
infix fun Expression.eq(value: Expression): Expression = Eq(this, value)
29+
30+
infix fun Expression.eq(value: Any?) = when (value) {
31+
null -> isNull
32+
else -> eq(value.asArgument())
33+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package kickstart.security
2+
3+
import com.vtence.kabinet.StatementExecutor
4+
import com.vtence.kabinet.insert
5+
import com.vtence.kabinet.selectWhere
6+
import kickstart.db.eq
7+
import security.Users
8+
import security.record
9+
import security.user
10+
11+
class UsersDatabase(private val db: StatementExecutor) : UserBase {
12+
13+
fun add(user: User) {
14+
Users.insert(user.record).execute(db)
15+
}
16+
17+
override fun findBy(username: Username): User? {
18+
return Users
19+
.selectWhere(Users.username eq username.value)
20+
.firstOrNull(db) { it.user }
21+
}
22+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package security
2+
3+
import com.vtence.kabinet.*
4+
import kickstart.security.PasswordHash
5+
import kickstart.security.User
6+
import kickstart.security.Username
7+
8+
9+
object Users: Table("users") {
10+
val id = int("id").autoGenerated()
11+
val username = string("username")
12+
val passwordHash = string("password_hash")
13+
}
14+
15+
16+
fun Users.hydrate(rs: ResultRow): User {
17+
return User(Username(rs[username]), PasswordHash.from(rs[passwordHash]))
18+
}
19+
20+
fun Users.dehydrate(st: Dataset, user: User) {
21+
st[username] = user.username.value
22+
st[passwordHash] = user.passwordHash
23+
}
24+
25+
26+
val ResultRow.user: User get() = Users.hydrate(this)
27+
28+
val User.record: Dehydrator<Users> get() = {
29+
dehydrate(it, this@record)
30+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
CREATE TABLE users
2+
(
3+
id SERIAL PRIMARY KEY,
4+
username TEXT UNIQUE NOT NULL,
5+
password_hash TEXT NOT NULL
6+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package kickstart.security
2+
3+
import com.natpryce.hamkrest.assertion.assertThat
4+
import com.natpryce.hamkrest.present
5+
import kickstart.AbstractDatabaseTest
6+
import org.junit.jupiter.api.BeforeEach
7+
import org.junit.jupiter.api.Test
8+
import org.junit.jupiter.api.assertThrows
9+
import security.UserBuilder
10+
import security.UserThat.hasSameStateAs
11+
import security.UserThat.hasUsername
12+
import security.Users
13+
import security.user
14+
import java.sql.SQLException
15+
16+
class UsersDatabaseTest : AbstractDatabaseTest() {
17+
18+
val db = UsersDatabase(executor)
19+
20+
@BeforeEach
21+
fun cleanDatabase() {
22+
clean(Users)
23+
}
24+
25+
@Test
26+
fun `round trips users`() {
27+
val samples = listOf(
28+
user { username = "dany@persuaders.com"; password = "american" },
29+
user { username = "brett@persuaders.com"; password = "british" }
30+
)
31+
32+
samples.forEach { assertCanBePersisted(it) }
33+
}
34+
35+
@Test
36+
fun `finds user by username`() {
37+
persist(
38+
user { username = "dany@persuaders.com" },
39+
user { username = "brett@persuaders.com" },
40+
user { username = "remington.steel@gmail.com"}
41+
)
42+
val match = db.findBy(Username("brett@persuaders.com"))
43+
44+
assertThat("found", match, present(hasUsername("brett@persuaders.com")))
45+
}
46+
47+
@Test
48+
fun `refuses to add account if username is already taken`() {
49+
persist(user { username = "remington.steel@gmail.com" })
50+
51+
assertThrows<SQLException> { db.add(user { username = "remington.steel@gmail.com" }.build()) }
52+
}
53+
54+
private fun assertCanBePersisted(user: UserBuilder) {
55+
assertReloadsWithSameState(persisted(user))
56+
}
57+
58+
private fun assertReloadsWithSameState(original: User) {
59+
val persisted: User? = db.findBy(original.username)
60+
assertThat("persisted entity", persisted, present(hasSameStateAs(original)))
61+
}
62+
63+
private fun persist(vararg users: UserBuilder) {
64+
users.forEach { persisted(it) }
65+
}
66+
67+
private fun persisted(user: UserBuilder): User {
68+
return transaction {
69+
user.build().also { db.add(it) }
70+
}
71+
}
72+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package kickstart
2+
3+
import com.vtence.kabinet.StatementExecutor
4+
import com.vtence.kabinet.Table
5+
import kickstart.db.DataSources
6+
import kickstart.db.DatabaseCleaner
7+
import kickstart.db.DatabaseMigrator
8+
import kickstart.db.JdbcTransactor
9+
import org.junit.jupiter.api.AfterEach
10+
import org.junit.jupiter.api.BeforeAll
11+
import org.junit.jupiter.api.BeforeEach
12+
import java.util.logging.Level
13+
import java.util.logging.LogManager
14+
import java.util.logging.Logger
15+
16+
abstract class AbstractDatabaseTest {
17+
18+
private val dataSource = DataSources.test(9999)
19+
private var connection = dataSource.connection
20+
private val transactor = JdbcTransactor(connection)
21+
22+
protected val executor = StatementExecutor(connection)
23+
private val migrator = DatabaseMigrator(dataSource)
24+
private val cleaner = DatabaseCleaner(transactor, executor)
25+
26+
@BeforeEach
27+
fun prepareDatabase() {
28+
migrator.migrate()
29+
}
30+
31+
@AfterEach
32+
fun closeConnection() {
33+
connection.close()
34+
}
35+
36+
fun clean(vararg tables: Table) {
37+
cleaner.clean(*tables)
38+
}
39+
40+
fun <T> transaction(work: UnitOfWork<T>): T = transactor(work)
41+
42+
companion object {
43+
@JvmStatic
44+
@BeforeAll
45+
fun silenceLogging() {
46+
Logging.silence()
47+
}
48+
}
49+
}
50+
51+
private object Logging {
52+
fun silence() {
53+
LogManager.getLogManager().reset()
54+
val globalLogger = Logger.getLogger(Logger.GLOBAL_LOGGER_NAME)
55+
globalLogger.level = Level.OFF
56+
}
57+
}
58+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package kickstart.db
2+
3+
4+
import com.vtence.kabinet.AutoSelectDataSource
5+
import com.vtence.kabinet.Delete
6+
import com.vtence.kabinet.StatementExecutor
7+
import com.vtence.kabinet.Table
8+
import kickstart.Transactor
9+
import org.flywaydb.core.Flyway
10+
import javax.sql.DataSource
11+
12+
13+
object DataSources {
14+
fun test(port: Int = 5432): DataSource {
15+
return AutoSelectDataSource("jdbc:postgresql://127.0.0.1:$port/kickstart_test", "test", "test", autoCommit = false)
16+
}
17+
}
18+
19+
20+
class DatabaseMigrator(dataSource: DataSource) {
21+
private val flyway = Flyway
22+
.configure()
23+
.sqlMigrationPrefix("")
24+
.sqlMigrationSeparator("_")
25+
.dataSource(dataSource)
26+
.placeholderReplacement(false)
27+
.table("schema_history")
28+
.load()
29+
30+
fun migrate() {
31+
flyway.migrate()
32+
}
33+
}
34+
35+
class DatabaseCleaner(private val transactor: Transactor, private val executor: StatementExecutor) {
36+
37+
fun clean(vararg tables: Table) {
38+
tables.map { delete(it) }
39+
}
40+
41+
private fun delete(table: Table) {
42+
transactor {
43+
Delete.from(table).execute(executor)
44+
}
45+
}
46+
}

‎settings.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ rootProject.name = "kickstart"
33
include("domain")
44
include("webapp")
55
include("server")
6+
include("integration")

‎webapp/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ plugins {
44

55
dependencies {
66
implementation(project(":domain"))
7+
implementation(project(":integration"))
78

89
implementation(libs.jmustache)
910
implementation(libs.konfig)

‎webapp/src/test/kotlin/security/SessionsControllerTest.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class SessionsControllerTest {
1616

1717
val view = TestView<Login>()
1818
val authenticator: Authenticator = Authenticator { (email, password) ->
19-
password.takeIf { it == "secret" }?.let { User(email) }
19+
password.takeIf { it == "secret" }?.let { User(email, password) }
2020
}
2121
val sessions = SessionsController(authenticator, view)
2222

0 commit comments

Comments
 (0)
Please sign in to comment.