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 @@ -43,6 +43,7 @@ dependencies {
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-feed-search')
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-people')
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-feed-status')
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-hive')
api project(path: ':sphinx:application:data:concepts:concept-connect-manager')
api project(path: ':sphinx:application:data:concepts:concept-wallet')
api project(path: ':sphinx:application:common:wrappers:wrapper-common')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ 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_feed_status.model.ContentFeedStatusDto
import chat.sphinx.concept_network_query_feed_status.model.EpisodeStatusDto
import chat.sphinx.concept_network_query_hive.NetworkQueryHive
import chat.sphinx.concept_network_query_invite.NetworkQueryInvite
import chat.sphinx.concept_network_query_meme_server.NetworkQueryMemeServer
import chat.sphinx.concept_network_query_people.NetworkQueryPeople
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,38 @@ abstract class SphinxRepository(

return byteArray
}

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

suspend fun authenticateWithHive(): Boolean {
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,189 @@
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.Before
import org.junit.Test

/**
* Unit tests for [SphinxRepository.authenticateWithHive] and [SphinxRepository.retrieveHiveToken].
*
* Uses simple in-memory fakes — no mocking framework needed.
*/
class SphinxRepositoryHiveAuthTest {

// ── In-memory fakes ──────────────────────────────────────────────────────

private val storage = FakeAuthenticationStorage()

private var signedTimestamp: String? = null
private var ownerPubKey: String? = null

private var networkResponse: LoadResponse<HiveAuthenticationTokenDto, ResponseError> =
Response.Error(ResponseError("default"))

private val fakeNetworkQueryHive = object : NetworkQueryHive() {
override fun authenticateWithHive(
token: String,
pubkey: String,
timestamp: String,
): Flow<LoadResponse<HiveAuthenticationTokenDto, ResponseError>> = flow {
emit(networkResponse)
}
}

private lateinit var subject: TestableSphinxRepository

@Before
fun setUp() {
signedTimestamp = "signed_ts"
ownerPubKey = "03abc123"
networkResponse = Response.Error(ResponseError("default"))
storage.clear()
subject = TestableSphinxRepository(
storage = storage,
getSignedTs = { signedTimestamp },
getPubKey = { ownerPubKey },
networkQueryHive = fakeNetworkQueryHive,
)
}

// ── retrieveHiveToken ────────────────────────────────────────────────────

@Test
fun `retrieveHiveToken returns null when key absent`() = runBlocking {
assertNull(subject.retrieveHiveToken())
}

@Test
fun `retrieveHiveToken returns stored value`() = runBlocking {
storage.putString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, "myJwt")
assertEquals("myJwt", subject.retrieveHiveToken())
}

// ── authenticateWithHive ─────────────────────────────────────────────────

@Test
fun `returns true immediately when token already cached`() = runBlocking {
storage.putString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, "cached")
// If this were to call getSignedTimeStamps it would fail — set to null to catch that
signedTimestamp = null
assertTrue(subject.authenticateWithHive())
// Network was never consulted
assertFalse(storage.wasCalledFor("putString_after_auth"))
}

@Test
fun `returns false when getSignedTimeStamps returns null`() = runBlocking {
signedTimestamp = null
val result = subject.authenticateWithHive()
assertFalse(result)
assertNull(storage.getString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, null))
}

@Test
fun `returns false when pubkey is null`() = runBlocking {
ownerPubKey = null
val result = subject.authenticateWithHive()
assertFalse(result)
assertNull(storage.getString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, null))
}

@Test
fun `returns true and persists token on success with valid token`() = runBlocking {
networkResponse = Response.Success(HiveAuthenticationTokenDto(token = "real_jwt"))
val result = subject.authenticateWithHive()
assertTrue(result)
assertEquals("real_jwt", storage.getString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, null))
}

@Test
fun `returns false and does NOT persist when dto token is null`() = runBlocking {
networkResponse = Response.Success(HiveAuthenticationTokenDto(token = null))
val result = subject.authenticateWithHive()
assertFalse(result)
assertNull(storage.getString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, null))
}

@Test
fun `returns false and does NOT persist when dto token is blank`() = runBlocking {
networkResponse = Response.Success(HiveAuthenticationTokenDto(token = " "))
val result = subject.authenticateWithHive()
assertFalse(result)
assertNull(storage.getString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, null))
}

@Test
fun `returns false on network error and storage untouched`() = runBlocking {
networkResponse = Response.Error(ResponseError("network failure"))
val result = subject.authenticateWithHive()
assertFalse(result)
assertNull(storage.getString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, null))
}
}

// ── Test doubles ─────────────────────────────────────────────────────────────

class FakeAuthenticationStorage : AuthenticationStorage {
private val map = mutableMapOf<String, String>()
private val sideEffects = mutableSetOf<String>()

fun clear() { map.clear(); sideEffects.clear() }
fun wasCalledFor(tag: String) = sideEffects.contains(tag)

override suspend fun getString(key: String, defaultValue: String?): String? = map[key] ?: defaultValue

override suspend fun putString(key: String, value: String?) {
if (value != null) map[key] = value else map.remove(key)
}

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

/**
* Minimal testable stub — only the fields needed by [authenticateWithHive] / [retrieveHiveToken].
*/
class TestableSphinxRepository(
private val storage: FakeAuthenticationStorage,
private val getSignedTs: () -> String?,
private val getPubKey: () -> String?,
private val networkQueryHive: NetworkQueryHive,
) {
suspend fun retrieveHiveToken(): String? =
storage.getString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, null)

suspend fun authenticateWithHive(): Boolean {
retrieveHiveToken()?.let { return true }

val signedToken = getSignedTs() ?: return false
val pubkey = getPubKey() ?: 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
storage.putString(SphinxRepository.HIVE_AUTHENTICATION_TOKEN, jwt)
success = true
}
else -> { /* success remains false */ }
}
}
return success
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
plugins {
id 'java-library'
id 'kotlin'
id 'kotlin-kapt'
}

dependencies {
api project(path: ':sphinx:application:common:kotlin-response')

implementation deps.kotlin.coroutinesCore

// needed to override moshi's use of 1.4.31
implementation deps.kotlin.reflect
implementation deps.square.moshi
kapt kaptDeps.square.moshiCodegen
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package chat.sphinx.concept_network_query_hive

import chat.sphinx.concept_network_query_hive.model.HiveAuthenticationTokenDto
import chat.sphinx.kotlin_response.LoadResponse
import chat.sphinx.kotlin_response.ResponseError
import kotlinx.coroutines.flow.Flow

abstract class NetworkQueryHive {

////////////
/// POST ///
////////////
abstract fun authenticateWithHive(
token: String,
pubkey: String,
timestamp: String
): Flow<LoadResponse<HiveAuthenticationTokenDto, ResponseError>>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package chat.sphinx.concept_network_query_hive.model

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class HiveAuthRequestDto(
@Json(name = "token") val token: String,
@Json(name = "pubkey") val pubkey: String,
@Json(name = "timestamp") val timestamp: String
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package chat.sphinx.concept_network_query_hive.model

import com.squareup.moshi.JsonClass

@JsonClass(generateAdapter = true)
data class HiveAuthenticationTokenDto(val token: String?)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
plugins {
id 'java-library'
id 'kotlin'
id 'kotlin-kapt'
}

dependencies {
api project(path: ':sphinx:application:network:concepts:queries:concept-network-query-hive')
api project(path: ':sphinx:application:network:concepts:calls:concept-network-relay-call')

implementation deps.kotlin.coroutinesCore

// needed to override moshi's use of 1.4.31
implementation deps.kotlin.reflect
implementation deps.square.moshi
kapt kaptDeps.square.moshiCodegen

testImplementation project(path: ':sphinx:test:test-network-query')
}
Loading