diff --git a/client/src/test/kotlin/io/flamenet/client/RelayAccountsTest.kt b/client/src/test/kotlin/io/flamenet/client/RelayAccountsTest.kt new file mode 100644 index 0000000..edec8c8 --- /dev/null +++ b/client/src/test/kotlin/io/flamenet/client/RelayAccountsTest.kt @@ -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 { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + accounts().signIn("ada", "hunter2hunter2") + } + } +}