Public pages deployed, and one credential across two products
Item 7: the comparison, landing and donation pages were written on a branch and never deployed, so the live site described a product that stopped being accurate several changes ago. Cherry-picked and deployed; the comparison page now says relays federate, that an address is derived rather than issued, and why federating is a trade rather than a win. Donation flow verified end to end against production keys: out-of-range amounts are refused before Stripe is contacted at all, and a valid amount creates a real Checkout Session and redirects to it. No payment was made -- an unpaid session expires on its own. ⚠️ I could NOT verify with test keys: there is no Stripe test key on the box, and inventing one proves nothing. Item 9, which is a narrower claim than it looks and worth testing exactly: **hosting only Messenger leaves nothing missing.** Proven three ways -- a local relay names no account service at all, runs a full sign-up and sign-in cycle with nothing else involved, and never probes for Files. And when the shared service IS used, one handle and passphrase signs in to both Messenger and Files. The two sessions carry DIFFERENT pseudonyms, which is the point rather than a detail: same-pseudonym would let the two services correlate their users, which is exactly what a shared account service must not enable. A Files-scoped token is also refused by the Messenger relay -- the product scope is a boundary, not a label. ⚠️ The shared-account tests now back off and retry on the account service's rate limit, because a dozen registrations in a burst is something any sane service should refuse. They still fail if it never clears: a rate limit that does not lift is a real problem, and skipping on it would hide one.
This commit is contained in:
@@ -31,8 +31,15 @@ class AccountsClient(
|
||||
|
||||
private val base = baseUrl.trimEnd('/').toHttpUrl()
|
||||
|
||||
/** The product name this client asks for a session under. */
|
||||
private val product = "messenger"
|
||||
/**
|
||||
* The product a session is scoped to.
|
||||
*
|
||||
* ⚠️ **Sessions are per product, and that is the privacy property.** The
|
||||
* token carries a pseudonym derived for this product, so Messenger and
|
||||
* Files receive different opaque ids for the same person and cannot
|
||||
* correlate their users with each other.
|
||||
*/
|
||||
private val defaultProduct = "messenger"
|
||||
|
||||
/**
|
||||
* What the service advertises for a handle.
|
||||
@@ -66,7 +73,7 @@ class AccountsClient(
|
||||
)
|
||||
}
|
||||
|
||||
fun signIn(handle: String, passphrase: String): Session {
|
||||
fun signIn(handle: String, passphrase: String, product: String = defaultProduct): Session {
|
||||
val params = parameters(handle)
|
||||
// ⚠️ The ADVERTISED iteration count wins over the local constant. An
|
||||
// account created under an older count must still be derivable, and the
|
||||
|
||||
@@ -67,6 +67,30 @@ class LiveShapesTest {
|
||||
|
||||
private fun handle(tag: String) = "shape$tag" + (System.nanoTime() % 10_000_000)
|
||||
|
||||
/**
|
||||
* Register against the shared account service, pacing for its rate limit.
|
||||
*
|
||||
* ⚠️ The throttle is the service working correctly, not a client failure —
|
||||
* three rounds across two shapes is a dozen registrations in a burst, and
|
||||
* any sane account service refuses that. So this backs off and retries
|
||||
* rather than reporting a product bug, and still FAILS if the service never
|
||||
* lets us through: a rate limit that never clears is a real problem, and
|
||||
* skipping on it would hide one.
|
||||
*/
|
||||
private fun registerPaced(accounts: AccountsClient, name: String) {
|
||||
var wait = 15_000L
|
||||
repeat(5) { attempt ->
|
||||
try {
|
||||
accounts.register(name, PASSPHRASE)
|
||||
return
|
||||
} catch (e: AccountsClient.Failure.RateLimited) {
|
||||
if (attempt == 4) throw e
|
||||
Thread.sleep(wait)
|
||||
wait = (wait * 2).coerceAtMost(120_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- What each shape claims about itself ---------------------------------
|
||||
|
||||
@Test
|
||||
@@ -200,7 +224,7 @@ class LiveShapesTest {
|
||||
val name = handle(tag)
|
||||
val descriptor = RelayDiscovery.describe(base)!!
|
||||
val accounts = AccountsClient(descriptor.accountsUrl!!)
|
||||
accounts.register(name, PASSPHRASE)
|
||||
registerPaced(accounts, name)
|
||||
return RelayAccounts(base).sharedSession(accounts.signIn(name, PASSPHRASE).token, name)
|
||||
}
|
||||
|
||||
@@ -230,7 +254,7 @@ class LiveShapesTest {
|
||||
assertTrue(descriptor?.usesSharedAccounts == true, "$base is not a shared-identity relay")
|
||||
|
||||
val accounts = AccountsClient(descriptor!!.accountsUrl!!)
|
||||
accounts.register(name, PASSPHRASE)
|
||||
registerPaced(accounts, name)
|
||||
val session = accounts.signIn(name, PASSPHRASE)
|
||||
assertTrue(session.token.isNotEmpty(), "no session token from the account service")
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package io.flamenet.client
|
||||
|
||||
import okhttp3.Request
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import org.json.JSONObject
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* One credential, two products — and whether either is optional.
|
||||
*
|
||||
* The claim the product makes is narrow and worth testing exactly: **hosting
|
||||
* only Messenger leaves nothing missing.** Flamenet Files is not a dependency,
|
||||
* a fallback, or a hidden requirement. What the shared account service buys is
|
||||
* one credential across both, and nothing else.
|
||||
*
|
||||
* ⚠️ Creates a real account on `accounts.flamenet.io`. Enabled by
|
||||
* `FN_LIVE_MANAGED=1`.
|
||||
*/
|
||||
class LiveTwoProductsTest {
|
||||
|
||||
private val accountsUrl = "https://accounts.flamenet.io"
|
||||
private val messengerRelay = "https://relay.flamenet.io"
|
||||
private val filesHost = "https://files.flamenet.io"
|
||||
private val localRelay = "https://relay1.flamenet.io"
|
||||
|
||||
private fun needManaged() = assumeTrue(
|
||||
System.getenv("FN_LIVE_MANAGED") == "1",
|
||||
"set FN_LIVE_MANAGED=1 — this creates a real account")
|
||||
|
||||
private fun needFleet() = assumeTrue(
|
||||
System.getenv("FN_LIVE_FLEET") != null, "set FN_LIVE_FLEET")
|
||||
|
||||
/**
|
||||
* ⚠️ **The Messenger-only case, which is the one that matters.** A relay
|
||||
* keeping its own accounts must work with no account service and no Files
|
||||
* anywhere — no probe of either, no degraded mode, no missing feature.
|
||||
*/
|
||||
@Test
|
||||
fun `a messenger-only self-hoster needs no account service and no files`() {
|
||||
needFleet()
|
||||
val descriptor = RelayDiscovery.describe(localRelay)
|
||||
assertTrue(descriptor != null, "the relay served no descriptor")
|
||||
assertEquals(RelayDiscovery.Accounts.LOCAL, descriptor!!.accounts)
|
||||
assertTrue(descriptor.accountsUrl == null,
|
||||
"a local relay named an account service: ${descriptor.accountsUrl}")
|
||||
|
||||
// And it can actually run an account, start to finish, with nothing
|
||||
// else involved.
|
||||
val handle = "solo" + (System.nanoTime() % 10_000_000)
|
||||
val account = RelayAccounts(localRelay)
|
||||
.signUp(handle, "$handle@example.invalid", PASSPHRASE)
|
||||
assertEquals(handle, account.username)
|
||||
assertTrue(account.appPassword.isNotEmpty())
|
||||
assertEquals(account.userId, RelayAccounts(localRelay).signIn(handle, PASSPHRASE).userId)
|
||||
}
|
||||
|
||||
/** Files names the same account service Messenger's managed relay does. */
|
||||
@Test
|
||||
fun `files and the managed relay point at the same account service`() {
|
||||
needManaged()
|
||||
val http = RelayClient.defaultHttpClient()
|
||||
val filesConfig = http.newCall(
|
||||
Request.Builder().url("$filesHost/api/config").get().build()
|
||||
).execute().use { JSONObject(it.body?.string().orEmpty()) }
|
||||
|
||||
assertEquals(accountsUrl, filesConfig.getString("accountsURL"),
|
||||
"Files uses a different account service than Messenger")
|
||||
assertEquals(accountsUrl, RelayDiscovery.describe(messengerRelay)?.accountsUrl)
|
||||
}
|
||||
|
||||
/**
|
||||
* One handle and passphrase, signed in to both products.
|
||||
*
|
||||
* ⚠️ And the two tokens must carry **different** pseudonyms. That is the
|
||||
* whole reason sessions are product-scoped: if Messenger and Files received
|
||||
* the same opaque id, the two services could correlate their users, which
|
||||
* is exactly what a shared account service must not enable.
|
||||
*/
|
||||
@Test
|
||||
fun `one account signs in to both products without letting them correlate`() {
|
||||
needManaged()
|
||||
val accounts = AccountsClient(accountsUrl)
|
||||
val handle = "both" + (System.nanoTime() % 10_000_000)
|
||||
accounts.register(handle, PASSPHRASE)
|
||||
|
||||
val messengerSession = accounts.signIn(handle, PASSPHRASE, product = "messenger")
|
||||
val filesSession = accounts.signIn(handle, PASSPHRASE, product = "files")
|
||||
assertTrue(messengerSession.token.isNotEmpty(), "no messenger session")
|
||||
assertTrue(filesSession.token.isNotEmpty(), "no files session")
|
||||
|
||||
assertNotEquals(messengerSession.pseudonym, filesSession.pseudonym,
|
||||
"the two products got the SAME pseudonym — they could correlate their users")
|
||||
|
||||
// The messenger half is not merely issued, it is accepted: exchanged at
|
||||
// the relay for a working relay credential.
|
||||
val relayAccount = RelayAccounts(messengerRelay)
|
||||
.sharedSession(messengerSession.token, handle)
|
||||
assertTrue(relayAccount.appPassword.isNotEmpty(),
|
||||
"the relay refused a session the account service issued")
|
||||
|
||||
// ⚠️ A files token must NOT be accepted by the messenger relay. The
|
||||
// product scope is a boundary, not a label; if the relay took it, a
|
||||
// token minted for one product would work on another.
|
||||
val refused = runCatching {
|
||||
RelayAccounts(messengerRelay).sharedSession(filesSession.token, handle)
|
||||
}
|
||||
assertTrue(refused.isFailure,
|
||||
"the messenger relay accepted a token scoped to Files")
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PASSPHRASE = "a-long-enough-passphrase"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user