feat(02-02): add users migration, JIT PrincipalResolver, /api/v1/me route
This commit is contained in:
@@ -1,6 +1,8 @@
|
|||||||
package dev.ulfrx.recipe
|
package dev.ulfrx.recipe
|
||||||
|
|
||||||
|
import dev.ulfrx.recipe.auth.PrincipalResolver
|
||||||
import dev.ulfrx.recipe.auth.configureAuthentication
|
import dev.ulfrx.recipe.auth.configureAuthentication
|
||||||
|
import dev.ulfrx.recipe.auth.meRoute
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import io.ktor.server.application.install
|
import io.ktor.server.application.install
|
||||||
@@ -27,9 +29,9 @@ private data class Health(
|
|||||||
|
|
||||||
fun Application.module() {
|
fun Application.module() {
|
||||||
install(CallLogging) {
|
install(CallLogging) {
|
||||||
// Method + path + status only — never headers (D-23). Ktor 3.4.1 does not expose
|
// Method + path + status only — never headers (D-23). Ktor 3.4.1's CallLoggingConfig
|
||||||
// `redactHeader(...)` on CallLoggingConfig, so the only safe approach is to omit
|
// doesn't expose a header-redaction API, so the only safe approach is to omit header
|
||||||
// header data from the format entirely. Bearer tokens never reach the log.
|
// data from the format entirely. Bearer tokens never reach the log.
|
||||||
format { call ->
|
format { call ->
|
||||||
"${call.request.httpMethod.value} ${call.request.path()} -> ${call.response.status()?.value ?: "-"}"
|
"${call.request.httpMethod.value} ${call.request.path()} -> ${call.response.status()?.value ?: "-"}"
|
||||||
}
|
}
|
||||||
@@ -39,13 +41,15 @@ fun Application.module() {
|
|||||||
}
|
}
|
||||||
configureAuthentication()
|
configureAuthentication()
|
||||||
Database.migrate(this)
|
Database.migrate(this)
|
||||||
configureRouting()
|
Database.connect(this)
|
||||||
|
configureRouting(PrincipalResolver())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Application.configureRouting() {
|
fun Application.configureRouting(principalResolver: PrincipalResolver = PrincipalResolver()) {
|
||||||
routing {
|
routing {
|
||||||
get("/health") {
|
get("/health") {
|
||||||
call.respond(Health(status = "ok"))
|
call.respond(Health(status = "ok"))
|
||||||
}
|
}
|
||||||
|
meRoute(principalResolver)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,18 @@
|
|||||||
package dev.ulfrx.recipe
|
package dev.ulfrx.recipe
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariConfig
|
||||||
|
import com.zaxxer.hikari.HikariDataSource
|
||||||
import io.ktor.server.application.Application
|
import io.ktor.server.application.Application
|
||||||
import org.flywaydb.core.Flyway
|
import org.flywaydb.core.Flyway
|
||||||
|
import org.jetbrains.exposed.sql.Database as ExposedDatabase
|
||||||
import org.slf4j.LoggerFactory
|
import org.slf4j.LoggerFactory
|
||||||
|
|
||||||
object Database {
|
object Database {
|
||||||
private val log = LoggerFactory.getLogger(Database::class.java)
|
private val log = LoggerFactory.getLogger(Database::class.java)
|
||||||
|
|
||||||
fun migrate(app: Application) {
|
fun migrate(app: Application) {
|
||||||
val url =
|
val (url, user, password) = readConfig(app)
|
||||||
app.environment.config
|
|
||||||
.property("database.url")
|
|
||||||
.getString()
|
|
||||||
val user =
|
|
||||||
app.environment.config
|
|
||||||
.property("database.user")
|
|
||||||
.getString()
|
|
||||||
val password =
|
|
||||||
app.environment.config
|
|
||||||
.property("database.password")
|
|
||||||
.getString()
|
|
||||||
|
|
||||||
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
|
log.info("Connecting to {} as {} and running Flyway migrations", url, user)
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
Flyway
|
Flyway
|
||||||
.configure()
|
.configure()
|
||||||
@@ -38,4 +28,35 @@ object Database {
|
|||||||
throw IllegalStateException("Database unreachable or migration failed", ex)
|
throw IllegalStateException("Database unreachable or migration failed", ex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire Exposed to a Hikari pool against the same JDBC URL Flyway used.
|
||||||
|
* Must be called AFTER [migrate] so the schema is in place before the first
|
||||||
|
* Exposed transaction runs (Phase 2 D-26).
|
||||||
|
*/
|
||||||
|
fun connect(app: Application) {
|
||||||
|
val (url, user, password) = readConfig(app)
|
||||||
|
val ds =
|
||||||
|
HikariDataSource(
|
||||||
|
HikariConfig().apply {
|
||||||
|
jdbcUrl = url
|
||||||
|
username = user
|
||||||
|
setPassword(password)
|
||||||
|
maximumPoolSize = 10
|
||||||
|
minimumIdle = 2
|
||||||
|
poolName = "recipe-pool"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ExposedDatabase.connect(ds)
|
||||||
|
log.info("Exposed connected via Hikari pool '{}'", ds.poolName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Conf(val url: String, val user: String, val password: String)
|
||||||
|
|
||||||
|
private fun readConfig(app: Application): Conf =
|
||||||
|
Conf(
|
||||||
|
url = app.environment.config.property("database.url").getString(),
|
||||||
|
user = app.environment.config.property("database.user").getString(),
|
||||||
|
password = app.environment.config.property("database.password").getString(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ public fun Application.configureAuthentication(
|
|||||||
verifier(jwkProvider, authConfig.issuer) {
|
verifier(jwkProvider, authConfig.issuer) {
|
||||||
withIssuer(authConfig.issuer)
|
withIssuer(authConfig.issuer)
|
||||||
withAudience(authConfig.audience)
|
withAudience(authConfig.audience)
|
||||||
acceptLeeway(authConfig.leewaySeconds)
|
// D-21: clock-skew tolerance pinned at 30 seconds (acceptLeeway(30 seconds)).
|
||||||
|
// Honor the AuthConfig override if set lower for tests, but never exceed 30.
|
||||||
|
acceptLeeway(authConfig.leewaySeconds.coerceAtMost(30L))
|
||||||
}
|
}
|
||||||
validate { credential ->
|
validate { credential ->
|
||||||
val sub = credential.payload.subject
|
val sub = credential.payload.subject
|
||||||
|
|||||||
28
server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt
Normal file
28
server/src/main/kotlin/dev/ulfrx/recipe/auth/MeRoute.kt
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.auth.authenticate
|
||||||
|
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||||
|
import io.ktor.server.auth.principal
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protected `GET /api/v1/me` route per D-27.
|
||||||
|
*
|
||||||
|
* The `authenticate("authentik")` block must be installed by [configureAuthentication]
|
||||||
|
* before this route is registered. JIT user provisioning happens inside [PrincipalResolver]
|
||||||
|
* on every authenticated call so claim drift propagates without an explicit migration.
|
||||||
|
*/
|
||||||
|
public fun Route.meRoute(principalResolver: PrincipalResolver) {
|
||||||
|
authenticate("authentik") {
|
||||||
|
get("/api/v1/me") {
|
||||||
|
val jwt =
|
||||||
|
call.principal<JWTPrincipal>()
|
||||||
|
?: return@get call.respond(HttpStatusCode.Unauthorized)
|
||||||
|
val me = principalResolver.resolve(jwt)
|
||||||
|
call.respond(me)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import dev.ulfrx.recipe.shared.dto.MeResponse
|
||||||
|
import io.ktor.server.auth.jwt.JWTPrincipal
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import org.jetbrains.exposed.sql.TextColumnType
|
||||||
|
import org.jetbrains.exposed.sql.statements.StatementType
|
||||||
|
import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a Ktor [JWTPrincipal] to a persisted [MeResponse] via JIT upsert (D-25).
|
||||||
|
*
|
||||||
|
* One row per OIDC `sub`; email + display_name refresh on every authenticated
|
||||||
|
* request so claim drift in Authentik propagates without an explicit migration.
|
||||||
|
*
|
||||||
|
* Implementation detail: Postgres `INSERT ... ON CONFLICT (sub) DO UPDATE ...
|
||||||
|
* RETURNING *` is the atomic upsert per D-25. We issue raw SQL via Exposed's
|
||||||
|
* `exec()` so we can use `RETURNING` (Exposed 0.55.0 DSL `upsert` returns the
|
||||||
|
* insert count, not the row, and we need the generated id and updated_at). DAO
|
||||||
|
* is forbidden by CLAUDE.md #5; we use `exec` (DSL) not the DAO API.
|
||||||
|
*
|
||||||
|
* `newSuspendedTransaction(Dispatchers.IO)` is mandatory: a plain blocking
|
||||||
|
* Exposed transaction inside a suspend route exhausts the connection pool
|
||||||
|
* (CLAUDE.md #6, PITFALLS.md #5/#6).
|
||||||
|
*/
|
||||||
|
public class PrincipalResolver {
|
||||||
|
public suspend fun resolve(principal: JWTPrincipal): MeResponse {
|
||||||
|
val sub =
|
||||||
|
principal.payload.subject?.takeIf { it.isNotBlank() }
|
||||||
|
?: error("PrincipalResolver invoked with blank sub — should be blocked by AuthPlugin.validate")
|
||||||
|
val email = principal.payload.getClaim("email")?.asString().orEmpty()
|
||||||
|
val nameClaim = principal.payload.getClaim("name")?.asString()
|
||||||
|
val preferredUsername = principal.payload.getClaim("preferred_username")?.asString()
|
||||||
|
val displayName = nameClaim ?: preferredUsername ?: email.ifBlank { sub }
|
||||||
|
|
||||||
|
return newSuspendedTransaction(Dispatchers.IO) {
|
||||||
|
var row: MeResponse? = null
|
||||||
|
// INSERT...RETURNING returns a ResultSet, so we must mark this as a SELECT
|
||||||
|
// statement type — Exposed's `exec` defaults to executeUpdate() for INSERT
|
||||||
|
// statements which Postgres rejects with "A result was returned when none
|
||||||
|
// was expected."
|
||||||
|
exec(
|
||||||
|
stmt =
|
||||||
|
"""
|
||||||
|
INSERT INTO users (sub, email, display_name)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT (sub) DO UPDATE
|
||||||
|
SET email = EXCLUDED.email,
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
updated_at = now()
|
||||||
|
RETURNING id, sub, email, display_name
|
||||||
|
""".trimIndent(),
|
||||||
|
args =
|
||||||
|
listOf(
|
||||||
|
TextColumnType() to sub,
|
||||||
|
TextColumnType() to email,
|
||||||
|
TextColumnType() to displayName,
|
||||||
|
),
|
||||||
|
explicitStatementType = StatementType.SELECT,
|
||||||
|
) { rs ->
|
||||||
|
if (rs.next()) {
|
||||||
|
row =
|
||||||
|
MeResponse(
|
||||||
|
id = rs.getObject("id").toString(),
|
||||||
|
sub = rs.getString("sub"),
|
||||||
|
email = rs.getString("email"),
|
||||||
|
displayName = rs.getString("display_name"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row ?: error("Upsert RETURNING produced no row for sub=$sub")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt
Normal file
21
server/src/main/kotlin/dev/ulfrx/recipe/auth/UsersTable.kt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import org.jetbrains.exposed.sql.Table
|
||||||
|
import org.jetbrains.exposed.sql.javatime.timestamp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exposed DSL mapping for the Phase 2 `users` migration (D-24, D-26).
|
||||||
|
*
|
||||||
|
* DSL only — DAO is forbidden by CLAUDE.md #5. Phase 3 will add `households`,
|
||||||
|
* `memberships`, and `invites` tables; this table stays untouched at that point
|
||||||
|
* because identity is anchored on the OIDC `sub`.
|
||||||
|
*/
|
||||||
|
public object UsersTable : Table("users") {
|
||||||
|
public val id = uuid("id")
|
||||||
|
public val sub = text("sub")
|
||||||
|
public val email = text("email")
|
||||||
|
public val displayName = text("display_name")
|
||||||
|
public val createdAt = timestamp("created_at")
|
||||||
|
public val updatedAt = timestamp("updated_at")
|
||||||
|
override val primaryKey = PrimaryKey(id)
|
||||||
|
}
|
||||||
14
server/src/main/resources/db/migration/V1__users.sql
Normal file
14
server/src/main/resources/db/migration/V1__users.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
-- Phase 2 D-24: principal table for OIDC-resolved users.
|
||||||
|
-- Identity is the OIDC `sub` claim (UNIQUE); email/display_name are mutable claims
|
||||||
|
-- refreshed by the JIT upsert in PrincipalResolver. Phase 3 layers
|
||||||
|
-- households/memberships/invites on top of this; do NOT add household_id here.
|
||||||
|
CREATE TABLE users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
sub TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX users_sub_idx ON users(sub);
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
package dev.ulfrx.recipe
|
package dev.ulfrx.recipe
|
||||||
|
|
||||||
|
import dev.ulfrx.recipe.auth.AuthConfig
|
||||||
|
import dev.ulfrx.recipe.auth.JwtTestSupport
|
||||||
|
import dev.ulfrx.recipe.auth.configureAuthentication
|
||||||
import io.ktor.client.request.get
|
import io.ktor.client.request.get
|
||||||
import io.ktor.client.statement.bodyAsText
|
import io.ktor.client.statement.bodyAsText
|
||||||
import io.ktor.http.HttpStatusCode
|
import io.ktor.http.HttpStatusCode
|
||||||
@@ -15,10 +18,24 @@ class ApplicationTest {
|
|||||||
@Test
|
@Test
|
||||||
fun `health endpoint returns 200 with status ok`() =
|
fun `health endpoint returns 200 with status ok`() =
|
||||||
testApplication {
|
testApplication {
|
||||||
|
// Phase 2: configureRouting now wires `meRoute` under `authenticate("authentik")`,
|
||||||
|
// so the Authentication plugin must be installed before routing — otherwise the
|
||||||
|
// route DSL throws MissingApplicationPluginException at registration time.
|
||||||
|
// The verifier doesn't need to validate any tokens for this `/health` test, but
|
||||||
|
// the plugin must exist; reuse the test JWT support to keep the wiring honest.
|
||||||
|
val support = JwtTestSupport()
|
||||||
application {
|
application {
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json()
|
json()
|
||||||
}
|
}
|
||||||
|
configureAuthentication(
|
||||||
|
AuthConfig(
|
||||||
|
issuer = support.issuer,
|
||||||
|
audience = support.audience,
|
||||||
|
jwksUrl = "https://test-authentik.invalid/application/o/jwks/",
|
||||||
|
),
|
||||||
|
support.provider,
|
||||||
|
)
|
||||||
configureRouting()
|
configureRouting()
|
||||||
}
|
}
|
||||||
val response = client.get("/health")
|
val response = client.get("/health")
|
||||||
|
|||||||
145
server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt
Normal file
145
server/src/test/kotlin/dev/ulfrx/recipe/auth/MeRouteTest.kt
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package dev.ulfrx.recipe.auth
|
||||||
|
|
||||||
|
import com.zaxxer.hikari.HikariConfig
|
||||||
|
import com.zaxxer.hikari.HikariDataSource
|
||||||
|
import dev.ulfrx.recipe.shared.dto.MeResponse
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.statement.bodyAsText
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
|
import io.ktor.server.application.install
|
||||||
|
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||||
|
import io.ktor.server.routing.routing
|
||||||
|
import io.ktor.server.testing.testApplication
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.flywaydb.core.Flyway
|
||||||
|
import org.jetbrains.exposed.sql.Database as ExposedDatabase
|
||||||
|
import org.junit.AfterClass
|
||||||
|
import org.junit.BeforeClass
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration test for `GET /api/v1/me` — exercises the full
|
||||||
|
* Auth → PrincipalResolver → Postgres path against a real database (AUTH-03,
|
||||||
|
* AUTH-06, D-25, D-27).
|
||||||
|
*
|
||||||
|
* Uses Testcontainers `postgres:16` rather than ambient localhost Postgres so
|
||||||
|
* the test is hermetic across dev machines / CI. Flyway runs once per JVM
|
||||||
|
* process before any test executes; Exposed is connected through Hikari to
|
||||||
|
* the container.
|
||||||
|
*/
|
||||||
|
class MeRouteTest {
|
||||||
|
companion object {
|
||||||
|
private val postgres = PostgreSQLContainer("postgres:16")
|
||||||
|
private lateinit var dataSource: HikariDataSource
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@BeforeClass
|
||||||
|
fun setUpClass() {
|
||||||
|
postgres.start()
|
||||||
|
Flyway
|
||||||
|
.configure()
|
||||||
|
.dataSource(postgres.jdbcUrl, postgres.username, postgres.password)
|
||||||
|
.locations("classpath:db/migration")
|
||||||
|
.baselineOnMigrate(true)
|
||||||
|
.load()
|
||||||
|
.migrate()
|
||||||
|
dataSource =
|
||||||
|
HikariDataSource(
|
||||||
|
HikariConfig().apply {
|
||||||
|
jdbcUrl = postgres.jdbcUrl
|
||||||
|
username = postgres.username
|
||||||
|
setPassword(postgres.password)
|
||||||
|
maximumPoolSize = 4
|
||||||
|
poolName = "me-route-test-pool"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ExposedDatabase.connect(dataSource)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@AfterClass
|
||||||
|
fun tearDownClass() {
|
||||||
|
dataSource.close()
|
||||||
|
postgres.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authConfigFor(support: JwtTestSupport): AuthConfig =
|
||||||
|
AuthConfig(
|
||||||
|
issuer = support.issuer,
|
||||||
|
audience = support.audience,
|
||||||
|
jwksUrl = "https://test-authentik.invalid/application/o/jwks/",
|
||||||
|
leewaySeconds = 30,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `valid token creates user row and returns MeResponse`() =
|
||||||
|
testApplication {
|
||||||
|
val support = JwtTestSupport()
|
||||||
|
val resolver = PrincipalResolver()
|
||||||
|
application {
|
||||||
|
install(ContentNegotiation) { json() }
|
||||||
|
configureAuthentication(authConfigFor(support), support.provider)
|
||||||
|
routing { meRoute(resolver) }
|
||||||
|
}
|
||||||
|
val token = support.mint(sub = "auth0|user-create-123")
|
||||||
|
|
||||||
|
val response =
|
||||||
|
client.get("/api/v1/me") {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer $token")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(HttpStatusCode.OK, response.status)
|
||||||
|
val body = json.decodeFromString(MeResponse.serializer(), response.bodyAsText())
|
||||||
|
assertEquals("auth0|user-create-123", body.sub)
|
||||||
|
assertEquals("test@example.invalid", body.email)
|
||||||
|
assertEquals("Test User", body.displayName)
|
||||||
|
assertTrue(body.id.isNotBlank(), "id should be a server-generated UUID, was: ${body.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `second token with same sub updates email and display name without duplicating`() =
|
||||||
|
testApplication {
|
||||||
|
val support = JwtTestSupport()
|
||||||
|
val resolver = PrincipalResolver()
|
||||||
|
application {
|
||||||
|
install(ContentNegotiation) { json() }
|
||||||
|
configureAuthentication(authConfigFor(support), support.provider)
|
||||||
|
routing { meRoute(resolver) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val first =
|
||||||
|
client.get("/api/v1/me") {
|
||||||
|
header(
|
||||||
|
HttpHeaders.Authorization,
|
||||||
|
"Bearer ${support.mint(sub = "auth0|user-update-1", email = "old@example.invalid", name = "Old Name")}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.OK, first.status)
|
||||||
|
val firstBody = json.decodeFromString(MeResponse.serializer(), first.bodyAsText())
|
||||||
|
|
||||||
|
val second =
|
||||||
|
client.get("/api/v1/me") {
|
||||||
|
header(
|
||||||
|
HttpHeaders.Authorization,
|
||||||
|
"Bearer ${support.mint(sub = "auth0|user-update-1", email = "new@example.invalid", name = "New Name")}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(HttpStatusCode.OK, second.status)
|
||||||
|
val secondBody = json.decodeFromString(MeResponse.serializer(), second.bodyAsText())
|
||||||
|
|
||||||
|
assertEquals(firstBody.id, secondBody.id) // same row
|
||||||
|
assertEquals(firstBody.sub, secondBody.sub)
|
||||||
|
assertEquals("new@example.invalid", secondBody.email)
|
||||||
|
assertEquals("New Name", secondBody.displayName)
|
||||||
|
assertNotEquals(firstBody.email, secondBody.email)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user