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))
|
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) {
|
fun confirmPasswordReset(handle: String, code: String, newPassword: String) {
|
||||||
post("account/reset/confirm", JSONObject()
|
post("account/reset/confirm", JSONObject()
|
||||||
.put("handle", handle)
|
.put("handle", handle)
|
||||||
|
|||||||
@@ -796,6 +796,15 @@ class RelayClient(
|
|||||||
|
|
||||||
throw when (code) {
|
throw when (code) {
|
||||||
401 -> RelayException.Unauthorized(readable)
|
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)
|
404 -> RelayException.NotFound(path)
|
||||||
429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull())
|
429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull())
|
||||||
// The geo-gate, not the relay. Needs a sentence, not a number.
|
// 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.")
|
"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. */
|
/** 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")
|
class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body")
|
||||||
|
|
||||||
/** A 2xx whose body was not what the route promised. */
|
/** 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