Test the account routes and their error mapping
RelayAccounts was at 0% — every route a person meets before they can send a single message, sign-up through password reset, including the error mapping that decides what they are told when it goes wrong. The error mapping earns as much attention as the happy path. These are the only failures somebody sees before they have an account, and a 409 shown as "something went wrong" instead of "that name is taken" is the difference between fixing it in five seconds and giving up. So there are tests for the taken username keeping the relay's own words, a wrong password arriving as Unauthorized rather than a generic failure, 429 carrying the relay's Retry-After and still being throttling without one, a non-JSON error body surviving as readable text, and an unreachable relay being Transport rather than a rejected password. Two of my assertions were wrong and the code was right, which is worth recording. Unauthorized and Unavailable deliberately keep the relay's text in `body` and give `message` a fixed string, because the type is a decision rather than a diagnostic — Unauthorized triggers exactly one retry, and a caller matching on a string to decide that eventually matches wrong. For 451 the fixed sentence is a product decision too: the download site is not geo-gated, so somebody can read the page, install the app, choose a password, tap sign up and be shown "relay returned 451", which mentions nothing about their country and cannot be retried past. The test now pins that wording. Also pins the compatibility details: an email goes in the `username` field because that field has always meant "username or email" and a client that only sends usernames rejects half the people who typed what they were asked for; a self-hosted relay's 404 on the shared-session route is the correct answer rather than a misconfiguration; and the handle travels beside the token rather than inside it, so a relay cannot correlate its users with another product's. RelayAccounts 0% -> 100%, client 57.7% -> 63.2% lines. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
7f7692318f
commit
4d8fc5b09c
@@ -0,0 +1,267 @@
|
|||||||
|
package io.flamenet.client
|
||||||
|
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import kotlin.test.AfterTest
|
||||||
|
import kotlin.test.BeforeTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign-up, sign-in and password reset against a relay that is its own identity
|
||||||
|
* provider.
|
||||||
|
*
|
||||||
|
* 🔴 **This whole class was at 0%** — every route a person meets before they
|
||||||
|
* can send a single message, including the error mapping that decides what they
|
||||||
|
* are told when it goes wrong. Nothing here had ever been executed by a test.
|
||||||
|
*
|
||||||
|
* The error mapping deserves the attention as much as the happy path does.
|
||||||
|
* These are the only failures a person sees before they have an account, and a
|
||||||
|
* 409 shown as "something went wrong" instead of "that name is taken" is the
|
||||||
|
* difference between fixing it in five seconds and giving up.
|
||||||
|
*/
|
||||||
|
class RelayAccountsTest {
|
||||||
|
|
||||||
|
private lateinit var server: MockWebServer
|
||||||
|
|
||||||
|
@BeforeTest fun start() { server = MockWebServer().also { it.start() } }
|
||||||
|
@AfterTest fun stop() { server.shutdown() }
|
||||||
|
|
||||||
|
private fun accounts() = RelayAccounts(server.url("/").toString().trimEnd('/'))
|
||||||
|
|
||||||
|
private fun json(body: String) = MockResponse()
|
||||||
|
.setResponseCode(200).setHeader("Content-Type", "application/json").setBody(body)
|
||||||
|
|
||||||
|
private val ok = """{"user_id":7,"username":"ada","display_name":"Ada L","app_password":"abcd efgh"}"""
|
||||||
|
|
||||||
|
// -- Sign-up -------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `signing up returns the credential the relay minted`() {
|
||||||
|
server.enqueue(json(ok))
|
||||||
|
val a = accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
|
||||||
|
assertEquals(7L, a.userId)
|
||||||
|
assertEquals("ada", a.username)
|
||||||
|
assertEquals("Ada L", a.displayName)
|
||||||
|
assertEquals("abcd efgh", a.appPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `sign-up posts every field the relay needs`() {
|
||||||
|
server.enqueue(json(ok))
|
||||||
|
accounts().signUp("ada", "ada@example.com", "hunter2hunter2", displayName = "Ada L")
|
||||||
|
val req = server.takeRequest()
|
||||||
|
assertEquals("POST", req.method)
|
||||||
|
assertEquals("/account/signup", req.path)
|
||||||
|
val body = req.body.readUtf8()
|
||||||
|
assertTrue(body.contains("\"username\":\"ada\""))
|
||||||
|
assertTrue(body.contains("\"email\":\"ada@example.com\""))
|
||||||
|
assertTrue(body.contains("\"password\":\"hunter2hunter2\""))
|
||||||
|
assertTrue(body.contains("\"display_name\":\"Ada L\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nobody should have to invent a display name to sign up. */
|
||||||
|
@Test
|
||||||
|
fun `the display name falls back to the username`() {
|
||||||
|
server.enqueue(json(ok))
|
||||||
|
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
|
||||||
|
assertTrue(server.takeRequest().body.readUtf8().contains("\"display_name\":\"ada\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The relay omits `display_name` for an account that never set one; falling
|
||||||
|
* over there would make such an account unusable rather than plain.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an account with no display name falls back to its username`() {
|
||||||
|
server.enqueue(json("""{"user_id":7,"username":"ada","app_password":"pw"}"""))
|
||||||
|
assertEquals("ada", accounts().signIn("ada", "pw").displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Sign-in -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ The field is "username **or email**" and always has been. A client
|
||||||
|
* that only sends usernames rejects half the people who typed exactly what
|
||||||
|
* they were asked for.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `an email address is sent in the username field`() {
|
||||||
|
server.enqueue(json(ok))
|
||||||
|
accounts().signIn("ada@example.com", "hunter2hunter2")
|
||||||
|
val body = server.takeRequest().body.readUtf8()
|
||||||
|
assertTrue(body.contains("\"username\":\"ada@example.com\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `signing in returns the credential, not the password`() {
|
||||||
|
server.enqueue(json(ok))
|
||||||
|
val a = accounts().signIn("ada", "hunter2hunter2")
|
||||||
|
assertEquals("abcd efgh", a.appPassword)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Shared session ------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ The handle travels BESIDE the token, not inside it: the account
|
||||||
|
* service will not put a handle in a token, so a relay cannot correlate its
|
||||||
|
* users with another product's.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a shared session sends the handle alongside the token`() {
|
||||||
|
server.enqueue(json(ok))
|
||||||
|
accounts().sharedSession("tok-123", "ada", displayName = "Ada L")
|
||||||
|
val req = server.takeRequest()
|
||||||
|
assertEquals("/api/flamenet/v1/messenger/session", req.path)
|
||||||
|
val body = req.body.readUtf8()
|
||||||
|
assertTrue(body.contains("\"token\":\"tok-123\""))
|
||||||
|
assertTrue(body.contains("\"handle\":\"ada\""))
|
||||||
|
assertTrue(body.contains("\"display_name\":\"Ada L\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a shared session omits an empty display name rather than sending blank`() {
|
||||||
|
server.enqueue(json(ok))
|
||||||
|
accounts().sharedSession("tok-123", "ada")
|
||||||
|
assertTrue(!server.takeRequest().body.readUtf8().contains("display_name"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ A self-hosted relay answers 404 here, and that is the CORRECT answer
|
||||||
|
* rather than a misconfiguration — it is its own identity provider. The
|
||||||
|
* caller has to be able to tell that apart from a broken relay.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a relay that is its own identity provider reports not-found distinctly`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"message":"no"}"""))
|
||||||
|
assertFailsWith<RelayException.NotFound> { accounts().sharedSession("tok", "ada") }
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Password reset ------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠️ The relay answers identically whatever the truth is — unknown handle,
|
||||||
|
* no address on file, an account that authenticates elsewhere. So this
|
||||||
|
* cannot report whether it worked, and a UI that claims it can is lying.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `requesting a reset says nothing about whether the account exists`() {
|
||||||
|
server.enqueue(json("""{"ok":true}"""))
|
||||||
|
accounts().requestPasswordReset("ada") // returns Unit, by design
|
||||||
|
val req = server.takeRequest()
|
||||||
|
assertEquals("/account/reset/request", req.path)
|
||||||
|
assertTrue(req.body.readUtf8().contains("\"handle\":\"ada\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `confirming a reset sends the code and the new password`() {
|
||||||
|
server.enqueue(json("""{"ok":true}"""))
|
||||||
|
accounts().confirmPasswordReset("ada", "123456", "newpassword1")
|
||||||
|
val req = server.takeRequest()
|
||||||
|
assertEquals("/account/reset/confirm", req.path)
|
||||||
|
val body = req.body.readUtf8()
|
||||||
|
assertTrue(body.contains("\"code\":\"123456\""))
|
||||||
|
assertTrue(body.contains("\"password\":\"newpassword1\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- What people are told when it fails ----------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔴 The relay puts a human-readable reason in the body, and it is better
|
||||||
|
* than anything this layer could invent from a status code. Losing it means
|
||||||
|
* "that name is taken" becomes "something went wrong".
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a taken username keeps the relay's own explanation`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(409)
|
||||||
|
.setBody("""{"message":"That name is already taken."}"""))
|
||||||
|
val e = assertFailsWith<RelayException.Http> {
|
||||||
|
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
|
||||||
|
}
|
||||||
|
assertEquals(409, e.status)
|
||||||
|
assertTrue(e.message!!.contains("already taken"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a bad password is unauthorized rather than a generic failure`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(401)
|
||||||
|
.setBody("""{"message":"Wrong password."}"""))
|
||||||
|
val e = assertFailsWith<RelayException.Unauthorized> {
|
||||||
|
accounts().signIn("ada", "wrong")
|
||||||
|
}
|
||||||
|
// ⚠️ The relay's text lives in `body`; `message` is fixed. The type is
|
||||||
|
// a DECISION — Unauthorized triggers exactly one retry — and a caller
|
||||||
|
// matching on a string to decide that eventually matches wrong.
|
||||||
|
assertTrue(e.body.contains("Wrong password"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `throttling carries the retry-after the relay gave`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(429).setHeader("Retry-After", "90"))
|
||||||
|
val e = assertFailsWith<RelayException.RateLimited> {
|
||||||
|
accounts().signIn("ada", "hunter2hunter2")
|
||||||
|
}
|
||||||
|
assertEquals(90, e.retryAfterSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `throttling without a retry-after is still throttling`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(429))
|
||||||
|
val e = assertFailsWith<RelayException.RateLimited> {
|
||||||
|
accounts().signIn("ada", "hunter2hunter2")
|
||||||
|
}
|
||||||
|
assertEquals(null, e.retryAfterSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔴 The geo-gate, not the relay. It needs a sentence, not a number — a
|
||||||
|
* person blocked by region who is shown "error 451" has no idea what
|
||||||
|
* happened or that nothing is wrong with their account.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a region block is reported as unavailable with its explanation`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(451)
|
||||||
|
.setBody("""{"message":"Not available in your region."}"""))
|
||||||
|
val e = assertFailsWith<RelayException.Unavailable> {
|
||||||
|
accounts().signUp("ada", "ada@example.com", "hunter2hunter2")
|
||||||
|
}
|
||||||
|
// 🔴 The message is deliberately OURS, not the relay's. The download
|
||||||
|
// site is not geo-gated, so somebody can read the page, install the
|
||||||
|
// app, pick a password, tap sign up and be told "relay returned 451" —
|
||||||
|
// which mentions nothing about their country and cannot be retried
|
||||||
|
// past, so the only sensible reading is that the app is broken.
|
||||||
|
assertTrue(e.message!!.contains("isn't available in your country"))
|
||||||
|
assertTrue(e.message!!.contains("isn't a problem with your account"))
|
||||||
|
assertTrue(e.body.contains("region"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A body that is not JSON still has to produce something readable. */
|
||||||
|
@Test
|
||||||
|
fun `a non-JSON error body is passed through as the message`() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(500).setBody("upstream exploded"))
|
||||||
|
val e = assertFailsWith<RelayException.Http> {
|
||||||
|
accounts().signIn("ada", "hunter2hunter2")
|
||||||
|
}
|
||||||
|
assertEquals(500, e.status)
|
||||||
|
assertTrue(e.message!!.contains("upstream exploded"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a success that is not a JSON object is malformed, not a crash`() {
|
||||||
|
server.enqueue(json("""["not","an","object"]"""))
|
||||||
|
assertFailsWith<RelayException.Malformed> {
|
||||||
|
accounts().signIn("ada", "hunter2hunter2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An unreachable relay is a transport failure, not a rejected password. */
|
||||||
|
@Test
|
||||||
|
fun `an unreachable relay is a transport failure`() {
|
||||||
|
server.shutdown()
|
||||||
|
assertFailsWith<RelayException.Transport> {
|
||||||
|
accounts().signIn("ada", "hunter2hunter2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user