Tell somebody their address needs confirming, and let them do it
Client half of flamenet-relay!13, which gates a relay token on a proved email address. 🔴 **Without this the refusal is indistinguishable from a broken password.** An unverified account arrives as a bare 403; `RelayAuth` logs "minting failed" and falls back to Basic, which the e2e routes refuse. So the person sees calls that do not work and an inbox that does nothing — and nothing anywhere says the remedy is a code sitting in their inbox. ⚠️ **The status alone is not enough.** 403 is also "that device belongs to another account". Telling somebody to check their email for that would send them looking for a message that was never sent, so the relay's own `code` decides, not the number. A 403 with no code, an unparseable body, and the same code on a 500 all stay generic — three tests hold that. `RelayAccounts.verifyEmail` spends the code. Unauthenticated on purpose, exactly like reset confirm: the code IS the proof, and requiring a session would mean somebody kept out *because* they are unverified could never get in. ⚠️ **Kotlin nests block comments**, so a slash-star written inside a KDoc — as part of a route glob — opens a comment that never closes and takes the module down with ninety unrelated errors, none of them near the real line. Cost twenty minutes here; there is now a note in the file. Five tests, proven able to fail: removing the code check turns three red. All three Android suites pass.
This commit is contained in:
@@ -90,6 +90,17 @@ class RelayAccounts(
|
||||
post("account/reset/request", JSONObject().put("handle", handle))
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend an email verification code.
|
||||
*
|
||||
* ⚠️ Unauthenticated, exactly like reset confirm: the code IS the proof.
|
||||
* Requiring a signed-in session would mean somebody kept out *because* they
|
||||
* are unverified could never verify.
|
||||
*/
|
||||
fun verifyEmail(token: String) {
|
||||
post("account/verify", JSONObject().put("token", token))
|
||||
}
|
||||
|
||||
fun confirmPasswordReset(handle: String, code: String, newPassword: String) {
|
||||
post("account/reset/confirm", JSONObject()
|
||||
.put("handle", handle)
|
||||
|
||||
@@ -796,6 +796,15 @@ class RelayClient(
|
||||
|
||||
throw when (code) {
|
||||
401 -> RelayException.Unauthorized(readable)
|
||||
// ⚠️ The relay's own code, not the status. A 403 is also "that
|
||||
// device belongs to another account", and telling somebody to check
|
||||
// their email for that would send them looking for a message that
|
||||
// was never sent.
|
||||
403 -> {
|
||||
val relayCode = runCatching { JSONObject(body).optString("code") }.getOrNull()
|
||||
if (relayCode == "email_unverified") RelayException.EmailUnverified(readable)
|
||||
else RelayException.Http(code, readable)
|
||||
}
|
||||
404 -> RelayException.NotFound(path)
|
||||
429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull())
|
||||
// The geo-gate, not the relay. Needs a sentence, not a number.
|
||||
|
||||
@@ -42,6 +42,24 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
|
||||
"This isn't a problem with your account or your connection.")
|
||||
|
||||
/** Any other non-2xx. The body is kept because relays put the reason in it. */
|
||||
/**
|
||||
* The account exists and the credential is right; the address has not been
|
||||
* proved reachable yet.
|
||||
*
|
||||
* 🔴 **Its own type because everything else about it looks like a
|
||||
* credential failure.** Without this it arrives as a bare 403 and
|
||||
* [RelayAuth] logs "minting failed" before falling back to Basic — which
|
||||
* the e2e routes refuse — so the person sees calls not working and an inbox
|
||||
* that does nothing, and nothing anywhere says the remedy is a code sitting
|
||||
* in their email.
|
||||
*
|
||||
* ⚠️ Do not write the e2e route glob in a KDoc here: Kotlin NESTS block
|
||||
* comments, so a slash-star inside one opens a comment that never closes
|
||||
* and takes the whole module down with ninety unrelated errors.
|
||||
*/
|
||||
class EmailUnverified(val body: String) : RelayException(
|
||||
"this account's email address has not been confirmed yet")
|
||||
|
||||
class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body")
|
||||
|
||||
/** A 2xx whose body was not what the route promised. */
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* A 403 that means "check your email", told apart from every other 403.
|
||||
*
|
||||
* 🔴 **Without this it is indistinguishable from a credential problem.** An
|
||||
* unverified account arrives as a bare 403; `RelayAuth` logs "minting failed"
|
||||
* and falls back to Basic, which the e2e routes refuse. The person then sees
|
||||
* calls that do not work and an inbox that does nothing, and nothing anywhere
|
||||
* says the remedy is a code sitting in their inbox.
|
||||
*
|
||||
* ⚠️ The status alone is not enough: 403 is also "that device belongs to
|
||||
* another account". Telling somebody to check their email for that would send
|
||||
* them looking for a message that was never sent — so the relay's own `code` is
|
||||
* what decides, not the number.
|
||||
*/
|
||||
class EmailUnverifiedTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@BeforeTest fun start() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
}
|
||||
|
||||
@AfterTest fun stop() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun relay() = RelayClient(
|
||||
server.url("/").toString(), username = "ada", appPassword = "credential")
|
||||
|
||||
private fun refuse(code: String?, status: Int = 403) {
|
||||
val body = if (code == null) """{"message":"no"}"""
|
||||
else """{"code":"$code","message":"Check your email and enter the code."}"""
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(status)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unverified account is its own failure, not a generic 403`() {
|
||||
refuse("email_unverified")
|
||||
val failure = assertFailsWith<RelayException.EmailUnverified> {
|
||||
relay().listDevices(1)
|
||||
}
|
||||
assertTrue(
|
||||
failure.body.contains("code"),
|
||||
"the relay's sentence should survive: ${failure.body}"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔴 The distinction that matters. A device conflict is also a 403 and must
|
||||
* NOT be reported as an unverified address.
|
||||
*/
|
||||
@Test
|
||||
fun `another 403 is not mistaken for an unverified address`() {
|
||||
refuse("forbidden")
|
||||
val failure = assertFailsWith<RelayException.Http> { relay().listDevices(1) }
|
||||
assertEquals(403, failure.status)
|
||||
}
|
||||
|
||||
/** ⚠️ A 403 with no code at all — an old relay, or a proxy — stays generic. */
|
||||
@Test
|
||||
fun `a 403 with no code stays generic`() {
|
||||
refuse(null)
|
||||
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
|
||||
}
|
||||
|
||||
/** ⚠️ And a body that is not JSON must not throw a parse error. */
|
||||
@Test
|
||||
fun `an unparseable 403 body does not crash`() {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(403)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.setBody("<html>a proxy wrote this</html>")
|
||||
)
|
||||
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
|
||||
}
|
||||
|
||||
/** The code is only meaningful on a 403 — the same string on a 500 is not. */
|
||||
@Test
|
||||
fun `the code does not change the meaning of other statuses`() {
|
||||
refuse("email_unverified", status = 500)
|
||||
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user