Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ include ':sphinx:application:network:concepts:queries:concept-network-query-disc
include ':sphinx:application:network:features:queries:feature-network-query-discover-tribes'
include ':sphinx:application:network:concepts:queries:concept-network-query-feed-status'
include ':sphinx:application:network:features:queries:feature-network-query-feed-status'
include ':sphinx:application:network:concepts:queries:concept-network-query-hive'
include ':sphinx:application:network:features:queries:feature-network-query-hive'

// Activity
include ':sphinx:activity:hilt-qualifiers'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import chat.sphinx.concept_network_query_invite.NetworkQueryInvite
import chat.sphinx.concept_network_query_meme_server.NetworkQueryMemeServer
import chat.sphinx.concept_network_query_feed_search.NetworkQueryFeedSearch
import chat.sphinx.concept_network_query_feed_status.NetworkQueryFeedStatus
import chat.sphinx.concept_network_query_hive.NetworkQueryHive
import chat.sphinx.concept_network_query_people.NetworkQueryPeople
import chat.sphinx.concept_network_query_verify_external.NetworkQueryAuthorizeExternal
import chat.sphinx.concept_paging.PageSourceWrapper
Expand Down Expand Up @@ -61,6 +62,7 @@ class SphinxRepositoryAndroid(
networkQueryPeople: NetworkQueryPeople,
networkQueryFeedSearch: NetworkQueryFeedSearch,
networkQueryFeedStatus: NetworkQueryFeedStatus,
networkQueryHive: NetworkQueryHive,
connectManager: ConnectManager,
dataSyncManager: DataSyncManager,
walletDataHandler: WalletDataHandler,
Expand Down Expand Up @@ -88,6 +90,7 @@ class SphinxRepositoryAndroid(
networkQueryPeople,
networkQueryFeedSearch,
networkQueryFeedStatus,
networkQueryHive,
connectManager,
dataSyncManager,
walletDataHandler,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies {
api project(path: ':sphinx:application:network:concepts:concept-meme-input-stream')
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-meme-server')
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-discover-tribes')
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-hive')
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-chat')
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-contact')
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-invite')
Expand All @@ -62,4 +63,5 @@ dependencies {

testImplementation deps.square.sqlDelightJvm
testImplementation project(path: ':sphinx:test:test-network-query')
testImplementation testDeps.junit
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import chat.sphinx.concept_network_query_discover_tribes.NetworkQueryDiscoverTri
import chat.sphinx.concept_network_query_feed_search.NetworkQueryFeedSearch
import chat.sphinx.concept_network_query_feed_search.model.toFeedSearchResult
import chat.sphinx.concept_network_query_feed_status.NetworkQueryFeedStatus
import chat.sphinx.concept_network_query_hive.NetworkQueryHive
import chat.sphinx.concept_network_query_feed_status.model.ContentFeedStatusDto
import chat.sphinx.concept_network_query_feed_status.model.EpisodeStatusDto
import chat.sphinx.concept_network_query_invite.NetworkQueryInvite
Expand Down Expand Up @@ -208,6 +209,7 @@ abstract class SphinxRepository(
private val networkQueryPeople: NetworkQueryPeople,
private val networkQueryFeedSearch: NetworkQueryFeedSearch,
private val networkQueryFeedStatus: NetworkQueryFeedStatus,
private val networkQueryHive: NetworkQueryHive,
private val connectManager: ConnectManager,
private val dataSyncManager: DataSyncManager,
private val walletDataHandler: WalletDataHandler,
Expand Down Expand Up @@ -236,6 +238,7 @@ abstract class SphinxRepository(
// PersistentStorage Keys
const val REPOSITORY_LIGHTNING_BALANCE = "REPOSITORY_LIGHTNING_BALANCE"
const val REPOSITORY_PUSH_KEY = "REPOSITORY_PUSH_KEY"
const val HIVE_AUTHENTICATION_TOKEN = "HIVE_AUTHENTICATION_TOKEN"

const val MEDIA_KEY_SIZE = 32

Expand Down Expand Up @@ -9596,6 +9599,39 @@ abstract class SphinxRepository(

return byteArray
}

/////////////////
/// Hive Auth ///
/////////////////

suspend fun authenticateWithHive(): Boolean {
// Short-circuit: avoid redundant Rust FFI call + network round-trip if already authenticated
retrieveHiveToken()?.let { return true }

val signedToken = connectManager.getSignedTimeStamps() ?: return false
val pubkey = accountOwner.value?.nodePubKey?.value ?: return false
val timestamp = System.currentTimeMillis().toString()

var success = false
networkQueryHive.authenticateWithHive(signedToken, pubkey, timestamp)
.collect { response ->
when (response) {
is Response.Success -> {
val jwt = response.value.token
?.takeIf { it.isNotBlank() }
?: return@collect
authenticationStorage.putString(HIVE_AUTHENTICATION_TOKEN, jwt)
success = true
}
else -> { /* success remains false */ }
}
}
return success
}

suspend fun retrieveHiveToken(): String? {
return authenticationStorage.getString(HIVE_AUTHENTICATION_TOKEN, null)
}
}

@Suppress("NOTHING_TO_INLINE")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
package chat.sphinx.feature_repository

import chat.sphinx.concept_network_query_hive.NetworkQueryHive
import chat.sphinx.concept_network_query_hive.model.HiveAuthenticationTokenDto
import chat.sphinx.kotlin_response.LoadResponse
import chat.sphinx.kotlin_response.Response
import chat.sphinx.kotlin_response.ResponseError
import io.matthewnelson.concept_authentication.data.AuthenticationStorage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test

/**
* Unit tests for SphinxRepository.authenticateWithHive() and retrieveHiveToken().
*
* Uses a standalone helper class that exercises only the Hive auth logic
* without instantiating the full abstract SphinxRepository.
*/
class HiveAuthenticationUnitTest {

// ─────────────────────────────────────────────────────────────────────────
// Minimal fakes
// ─────────────────────────────────────────────────────────────────────────

private class FakeAuthenticationStorage(
initial: Map<String, String> = emptyMap()
) : AuthenticationStorage {
val storage = mutableMapOf<String, String?>().also { it.putAll(initial) }

override suspend fun getString(key: String, defaultValue: String?): String? =
if (storage.containsKey(key)) storage[key] else defaultValue

override suspend fun putString(key: String, value: String?) {
storage[key] = value
}

override suspend fun removeString(key: String) {
storage.remove(key)
}
}

private class FakeNetworkQueryHive(
private val responseBuilder: (String, String, String) -> Flow<LoadResponse<HiveAuthenticationTokenDto, ResponseError>>
) : NetworkQueryHive() {
var callCount = 0

override fun authenticateWithHive(
token: String,
pubkey: String,
timestamp: String
): Flow<LoadResponse<HiveAuthenticationTokenDto, ResponseError>> {
callCount++
return responseBuilder(token, pubkey, timestamp)
}
}

// ─────────────────────────────────────────────────────────────────────────
// Extracted logic under test (mirrors SphinxRepository implementation)
// ─────────────────────────────────────────────────────────────────────────

/**
* Encapsulates only the Hive auth logic from SphinxRepository so we can test
* it in isolation without the full dependency graph.
*/
private class HiveAuthLogic(
private val authenticationStorage: AuthenticationStorage,
private val networkQueryHive: NetworkQueryHive,
private val signedTimestampProvider: () -> String?,
private val pubkeyProvider: () -> String?
) {
suspend fun authenticateWithHive(): Boolean {
retrieveHiveToken()?.let { return true }

val signedToken = signedTimestampProvider() ?: return false
val pubkey = pubkeyProvider() ?: return false
val timestamp = System.currentTimeMillis().toString()

var success = false
networkQueryHive.authenticateWithHive(signedToken, pubkey, timestamp)
.collect { response ->
when (response) {
is Response.Success -> {
val jwt = response.value.token
?.takeIf { it.isNotBlank() }
?: return@collect
authenticationStorage.putString(
SphinxRepository.HIVE_AUTHENTICATION_TOKEN, jwt
)
success = true
}
else -> { /* success remains false */ }
}
}
return success
}

suspend fun retrieveHiveToken(): String? =
authenticationStorage.getString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, null)
}

// ─────────────────────────────────────────────────────────────────────────
// Tests — retrieveHiveToken
// ─────────────────────────────────────────────────────────────────────────

@Test
fun `retrieveHiveToken returns null when no token stored`() = runBlocking {
val storage = FakeAuthenticationStorage()
val query = FakeNetworkQueryHive { _, _, _ -> flow {} }
val logic = HiveAuthLogic(storage, query, { "sig" }, { "pk" })

assertNull(logic.retrieveHiveToken())
}

@Test
fun `retrieveHiveToken returns stored token`() = runBlocking {
val storage = FakeAuthenticationStorage(
mapOf(SphinxRepository.HIVE_AUTHENTICATION_TOKEN to "stored_jwt")
)
val query = FakeNetworkQueryHive { _, _, _ -> flow {} }
val logic = HiveAuthLogic(storage, query, { "sig" }, { "pk" })

assertEquals("stored_jwt", logic.retrieveHiveToken())
}

// ─────────────────────────────────────────────────────────────────────────
// Tests — authenticateWithHive
// ─────────────────────────────────────────────────────────────────────────

@Test
fun `returns true immediately when token already cached - no network call made`() = runBlocking {
val storage = FakeAuthenticationStorage(
mapOf(SphinxRepository.HIVE_AUTHENTICATION_TOKEN to "existing_jwt")
)
val query = FakeNetworkQueryHive { _, _, _ -> flow {} }
val logic = HiveAuthLogic(
storage, query,
signedTimestampProvider = { error("should not be called") },
pubkeyProvider = { error("should not be called") }
)

val result = logic.authenticateWithHive()

assertTrue(result)
assertEquals(0, query.callCount)
}

@Test
fun `returns false when signed token is null - no network call made`() = runBlocking {
val storage = FakeAuthenticationStorage()
val query = FakeNetworkQueryHive { _, _, _ -> flow {} }
val logic = HiveAuthLogic(storage, query,
signedTimestampProvider = { null },
pubkeyProvider = { "pubkey123" }
)

val result = logic.authenticateWithHive()

assertFalse(result)
assertEquals(0, query.callCount)
assertNull(storage.storage[SphinxRepository.HIVE_AUTHENTICATION_TOKEN])
}

@Test
fun `returns false when pubkey is null - no network call made`() = runBlocking {
val storage = FakeAuthenticationStorage()
val query = FakeNetworkQueryHive { _, _, _ -> flow {} }
val logic = HiveAuthLogic(storage, query,
signedTimestampProvider = { "signed_ts" },
pubkeyProvider = { null }
)

val result = logic.authenticateWithHive()

assertFalse(result)
assertEquals(0, query.callCount)
assertNull(storage.storage[SphinxRepository.HIVE_AUTHENTICATION_TOKEN])
}

@Test
fun `returns true and stores token on success with valid jwt`() = runBlocking {
val storage = FakeAuthenticationStorage()
val expectedJwt = "valid.hive.jwt"
val query = FakeNetworkQueryHive { _, _, _ ->
flow { emit(Response.Success(HiveAuthenticationTokenDto(expectedJwt))) }
}
val logic = HiveAuthLogic(storage, query,
signedTimestampProvider = { "signed_ts" },
pubkeyProvider = { "pubkey123" }
)

val result = logic.authenticateWithHive()

assertTrue(result)
assertEquals(expectedJwt, storage.storage[SphinxRepository.HIVE_AUTHENTICATION_TOKEN])
}

@Test
fun `returns false and does not store when response token is null`() = runBlocking {
val storage = FakeAuthenticationStorage()
val query = FakeNetworkQueryHive { _, _, _ ->
flow { emit(Response.Success(HiveAuthenticationTokenDto(null))) }
}
val logic = HiveAuthLogic(storage, query,
signedTimestampProvider = { "signed_ts" },
pubkeyProvider = { "pubkey123" }
)

val result = logic.authenticateWithHive()

assertFalse(result)
assertNull(storage.storage[SphinxRepository.HIVE_AUTHENTICATION_TOKEN])
}

@Test
fun `returns false and does not store when response token is blank`() = runBlocking {
val storage = FakeAuthenticationStorage()
val query = FakeNetworkQueryHive { _, _, _ ->
flow { emit(Response.Success(HiveAuthenticationTokenDto(" "))) }
}
val logic = HiveAuthLogic(storage, query,
signedTimestampProvider = { "signed_ts" },
pubkeyProvider = { "pubkey123" }
)

val result = logic.authenticateWithHive()

assertFalse(result)
assertNull(storage.storage[SphinxRepository.HIVE_AUTHENTICATION_TOKEN])
}

@Test
fun `returns false and does not store when response token is empty string`() = runBlocking {
val storage = FakeAuthenticationStorage()
val query = FakeNetworkQueryHive { _, _, _ ->
flow { emit(Response.Success(HiveAuthenticationTokenDto(""))) }
}
val logic = HiveAuthLogic(storage, query,
signedTimestampProvider = { "signed_ts" },
pubkeyProvider = { "pubkey123" }
)

val result = logic.authenticateWithHive()

assertFalse(result)
assertNull(storage.storage[SphinxRepository.HIVE_AUTHENTICATION_TOKEN])
}

@Test
fun `returns false on network error and storage is untouched`() = runBlocking {
val storage = FakeAuthenticationStorage()
val query = FakeNetworkQueryHive { _, _, _ ->
flow {
emit(Response.Error(ResponseError("network failure")))
}
}
val logic = HiveAuthLogic(storage, query,
signedTimestampProvider = { "signed_ts" },
pubkeyProvider = { "pubkey123" }
)

val result = logic.authenticateWithHive()

assertFalse(result)
assertNull(storage.storage[SphinxRepository.HIVE_AUTHENTICATION_TOKEN])
}

@Test
fun `HIVE_AUTHENTICATION_TOKEN constant has expected value`() {
assertEquals("HIVE_AUTHENTICATION_TOKEN", SphinxRepository.HIVE_AUTHENTICATION_TOKEN)
}
}
Loading