diff --git a/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql b/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql index 7c3071a7..b9b792de 100644 --- a/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql +++ b/app/src/main/graphql/io/github/nofuturekid/nova/queries.graphql @@ -204,3 +204,26 @@ query GetNetworkInterfaces { } } } + +# Capability detection (ADR-0047). Two operations on purpose: `me` is non-null, so for a key without +# ME:READ_ANY a FORBIDDEN on `me` would null the whole response and lose the version as well. +query GetApiVersion { + info { + versions { + core { + unraid + api + } + } + } +} + +query GetKeyAccess { + me { + roles + permissions { + resource + actions + } + } +} diff --git a/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls b/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls index df858758..82f17da0 100644 --- a/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls +++ b/app/src/main/graphql/io/github/nofuturekid/nova/schema.graphqls @@ -40,6 +40,7 @@ type Query { plugins: [Plugin!]! pluginInstallOperations: [PluginInstallOperation!]! display: InfoDisplay + me: UserAccount! } # ── Display (global temperature thresholds) ────────────────────────── @@ -265,6 +266,7 @@ type InfoVersions { type CoreVersions { unraid: String + api: String kernel: String } @@ -565,3 +567,68 @@ type DockerContainerStats { netIO: String! blockIO: String! } + +# ── API key access (capability detection, ADR-0047) ────────────────── +# Subset of the live Unraid `me` query. `me` describes the API key, not a +# signed-in user. Enum values are the Unraid 4.37.11 schema; a value a newer +# server adds arrives as UNKNOWN__ (Apollo keeps no raw name), which NOVA treats as unknown. + +type UserAccount { + roles: [Role!]! + permissions: [Permission!] +} + +type Permission { + resource: Resource! + actions: [AuthAction!]! +} + +enum Role { + ADMIN + CONNECT + GUEST + VIEWER +} + +enum AuthAction { + CREATE_ANY + CREATE_OWN + READ_ANY + READ_OWN + UPDATE_ANY + UPDATE_OWN + DELETE_ANY + DELETE_OWN +} + +enum Resource { + ACTIVATION_CODE + API_KEY + ARRAY + CLOUD + CONFIG + CONNECT + CONNECT__REMOTE_ACCESS + CUSTOMIZATIONS + DASHBOARD + DISK + DISPLAY + DOCKER + FLASH + INFO + LOGS + ME + NETWORK + NOTIFICATIONS + ONLINE + OS + OWNER + PERMISSION + REGISTRATION + SERVERS + SERVICES + SHARE + VARS + VMS + WELCOME +} diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/ServerCapabilities.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/ServerCapabilities.kt new file mode 100644 index 00000000..2973a587 --- /dev/null +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/model/ServerCapabilities.kt @@ -0,0 +1,178 @@ +package io.github.nofuturekid.nova.data.model + +/** + * What NOVA knows about the server it talks to and about what its API key may + * do (ADR-0047). Pure Kotlin on purpose: no Apollo and no Android types, so the + * rules below are unit-tested on the JVM. + * + * One rule runs through all of it: UNKNOWN IS NOT DENIED. A failed detection, a + * key whose `me` is refused and a role NOVA has never heard of all leave the + * answer at [Access.Unknown]. Only a successful detection can say [Access.Denied], + * so a transient error can never take a button away from the user. + */ + +/** The server address and API key that capabilities were detected for. Equality only; never logged. */ +data class CapabilityTarget(val serverId: String, val baseUrl: String, private val apiKey: String) { + override fun toString() = "CapabilityTarget($serverId, $baseUrl, key=***)" +} + +/** An Unraid API version, compared by number only (a build suffix is not part of the order). */ +data class ApiVersion(val major: Int, val minor: Int, val patch: Int) : Comparable { + override fun compareTo(other: ApiVersion) = compareValuesBy(this, other, { it.major }, { it.minor }, { it.patch }) + + override fun toString() = "$major.$minor.$patch" + + companion object { + private val LEADING_NUMBERS = Regex("""^\s*(\d+)\.(\d+)\.(\d+)""") + + /** Leading x.y.z of "4.37.11+8e587fe2", "4.36.1+f3205d97" or "4.37.11-2"; null for anything else. */ + fun parse(raw: String?): ApiVersion? { + val (a, b, c) = raw?.let { LEADING_NUMBERS.find(it) }?.destructured ?: return null + return ApiVersion( + a.toIntOrNull() ?: return null, + b.toIntOrNull() ?: return null, + c.toIntOrNull() ?: return null, + ) + } + } +} + +/** An API capability gated by the server's API version, with the first version that has it. */ +enum class ApiFeature(val since: ApiVersion) { + /** Every screen (#247). */ + Base(ApiVersion(4, 30, 0)), + + /** Documented here; the existing network-speed probe stays. */ + NetworkSpeed(ApiVersion(4, 35, 0)), + + /** `docker.restart` in one step (#317). */ + DockerRestart(ApiVersion(4, 36, 0)), +} + +/** + * A control NOVA offers and the permission the server checks for it. The single source for the + * hide-controls work (#315) and the documentation of key permissions (#316). + * + * [operations] are the names of the GraphQL operations in `mutations.graphql` behind the control. + */ +enum class KeyAction(val resource: String, val action: String, val operations: Set) { + ControlArray( + "ARRAY", "UPDATE_ANY", + setOf("StartArray", "StopArray", "StartParityCheck", "PauseParityCheck", "ResumeParityCheck", "CancelParityCheck"), + ), + ControlDocker( + "DOCKER", "UPDATE_ANY", + setOf( + "StartContainer", "StopContainer", "RestartContainer", "PauseContainer", "UnpauseContainer", + "UpdateContainer", "UpdateAllContainers", + ), + ), + ControlVms( + "VMS", "UPDATE_ANY", + setOf("StartVm", "StopVm", "PauseVm", "ResumeVm", "ForceStopVm", "RebootVm", "ResetVm"), + ), + UpdateNotifications( + "NOTIFICATIONS", "UPDATE_ANY", + setOf("ArchiveNotification", "UnreadNotification", "ArchiveAllNotifications", "RecalculateNotificationOverview"), + ), + DeleteNotifications( + "NOTIFICATIONS", "DELETE_ANY", + setOf("DeleteNotification", "DeleteArchivedNotifications"), + ), + ; + + /** The permission in the server's own spelling, e.g. `DOCKER:UPDATE_ANY`. */ + val permission: String get() = "$resource:$action" + + companion object { + fun forOperation(name: String): KeyAction? = entries.firstOrNull { name in it.operations } + } +} + +/** What the key may do. [Unknown] is not [Denied]: the UI hides a control only for [Denied]. */ +enum class Access { Allowed, Denied, Unknown } + +/** Whether the server's API has a feature. [Unknown] is not [No]. */ +enum class Support { Yes, No, Unknown } + +sealed interface KeyAccess { + data object Unknown : KeyAccess + + /** [roles] and [grants] as the server's raw enum names, e.g. "VIEWER" and "DOCKER:UPDATE_ANY". */ + data class Known(val roles: Set, val grants: Set) : KeyAccess +} + +enum class Detection { + /** Nothing detected yet (no server, or no key). */ + Idle, + + /** Detection for [ServerCapabilities.target] is running and no earlier result exists for it. */ + Detecting, + + /** The server answered; the version or the access (or both) is known. */ + Done, + + /** The server said `UNAUTHENTICATED`. Only ever set from that answer. */ + KeyRejected, + + /** No usable answer (unreachable, server error). Version and access stay unknown. */ + Failed, +} + +data class ServerCapabilities( + val target: CapabilityTarget? = null, + val detection: Detection = Detection.Idle, + val unraidVersion: String? = null, + /** The raw API version, for display ("4.37.11+8e587fe2"). */ + val apiVersionText: String? = null, + val apiVersion: ApiVersion? = null, + /** The version query failed validation, so the API is older than 4.15.0 (the field does not exist there). */ + val versionFieldMissing: Boolean = false, + val access: KeyAccess = KeyAccess.Unknown, +) { + /** + * Whether the key may use [a]: [Access.Allowed] when the key holds the permission explicitly or one of + * its roles grants it; [Access.Unknown] when nothing is known or a role is unknown to NOVA and no known + * role grants it (a future role must never hide a control); otherwise [Access.Denied]. + */ + fun access(a: KeyAction): Access { + val known = access as? KeyAccess.Known ?: return Access.Unknown + if (a.permission in known.grants) return Access.Allowed + val verdicts = known.roles.map { roleGrants(it, a.resource, a.action) } + return when { + verdicts.any { it == true } -> Access.Allowed + verdicts.any { it == null } -> Access.Unknown + else -> Access.Denied + } + } + + fun supports(f: ApiFeature): Support = when { + versionFieldMissing -> Support.No + apiVersion == null -> Support.Unknown + apiVersion >= f.since -> Support.Yes + else -> Support.No + } + + /** True only when the server proved it: the version is below the floor, or the version field does not exist. */ + val apiTooOld: Boolean get() = supports(ApiFeature.Base) == Support.No + + companion object { + val UNKNOWN = ServerCapabilities() + } +} + +/** + * Static mirror of the Unraid API's role policy (`api/src/unraid-api/auth/casbin/policy.ts`; identical from + * v4.30.0 to v4.37.11 and main). `true` = the role grants it, `false` = it does not, `null` = a role NOVA + * does not know. The server compares the action exactly: UPDATE_ANY does not imply READ_ANY and `*_OWN` + * never satisfies an `*_ANY` check. + */ +internal fun roleGrants(role: String, resource: String, action: String): Boolean? = when (role) { + "ADMIN" -> true + "VIEWER" -> action == "READ_ANY" && resource != "API_KEY" + // CONNECT inherits VIEWER and may update its own remote-access setting. + "CONNECT" -> (action == "READ_ANY" && resource != "API_KEY") || + (resource == "CONNECT__REMOTE_ACCESS" && action == "UPDATE_ANY") + "GUEST" -> resource == "ME" && action == "READ_ANY" + else -> null +} diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityProbe.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityProbe.kt new file mode 100644 index 00000000..f20ed075 --- /dev/null +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityProbe.kt @@ -0,0 +1,150 @@ +package io.github.nofuturekid.nova.data.repository + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.Error +import io.github.nofuturekid.nova.data.model.ApiVersion +import io.github.nofuturekid.nova.data.model.CapabilityTarget +import io.github.nofuturekid.nova.data.model.Detection +import io.github.nofuturekid.nova.data.model.KeyAccess +import io.github.nofuturekid.nova.data.model.ServerCapabilities +import io.github.nofuturekid.nova.graphql.GetApiVersionQuery +import io.github.nofuturekid.nova.graphql.GetKeyAccessQuery +import kotlinx.coroutines.CancellationException + +/** + * Detection of the server's API version and the API key's access (ADR-0047): the two + * operations `GetApiVersion` and `GetKeyAccess`, and the classification of what the server + * answers. The mapping from answers to capabilities lives here, not in `GraphQlMapper.kt`. + * + * The rule for every branch: only a SUCCESSFUL answer can take something away. A refused or + * failed `me` leaves the access unknown (controls stay visible, as before this feature); + * only `UNAUTHENTICATED` from the server marks the key as rejected, never an inference. + */ + +/** The result of one detection, before it is tied to a target (see [toCapabilities]). */ +data class ProbeOutcome( + val detection: Detection, + val unraidVersion: String? = null, + val apiVersionText: String? = null, + val apiVersion: ApiVersion? = null, + /** The version query failed validation: the API is older than 4.15.0. */ + val versionFieldMissing: Boolean = false, + val access: KeyAccess = KeyAccess.Unknown, +) { + fun toCapabilities(target: CapabilityTarget?) = ServerCapabilities( + target = target, + detection = detection, + unraidVersion = unraidVersion, + apiVersionText = apiVersionText, + apiVersion = apiVersion, + versionFieldMissing = versionFieldMissing, + access = access, + ) +} + +/** What the `errors` of one GraphQL response say, by `extensions.code`. First match wins in this order. */ +internal enum class ErrorClass { KeyRejected, ValidationFailed, Forbidden, Other } + +internal fun classifyErrors(errors: List): ErrorClass = when { + errors.any { it.code() == "UNAUTHENTICATED" } -> ErrorClass.KeyRejected + // Apollo parses Unraid's HTTP 400 `application/graphql-response+json` body into `errors`. + errors.any { it.code() == "GRAPHQL_VALIDATION_FAILED" || it.message.startsWith("Cannot query field") } -> + ErrorClass.ValidationFailed + errors.any { it.code() == "FORBIDDEN" } -> ErrorClass.Forbidden + else -> ErrorClass.Other +} + +private fun Error.code(): Any? = extensions?.get("code") + +private sealed interface VersionStep { + data class Got(val unraid: String?, val apiText: String?) : VersionStep + data object Missing : VersionStep + data object Unknown : VersionStep + data object Rejected : VersionStep + data object Failed : VersionStep +} + +private sealed interface AccessStep { + data class Got(val access: KeyAccess.Known) : AccessStep + data object Unknown : AccessStep + data object Rejected : AccessStep + data object TransportFailed : AccessStep +} + +/** + * Sends `GetApiVersion`, then `GetKeyAccess`, and classifies each answer. Never throws for a + * server or transport problem (an [com.apollographql.apollo.exception.ApolloException] counts as + * a transport failure); only cancellation passes through. + */ +internal suspend fun probeCapabilities(client: ApolloClient): ProbeOutcome { + val version = probeVersion(client) + when (version) { + VersionStep.Rejected -> return ProbeOutcome(Detection.KeyRejected) + VersionStep.Failed -> return ProbeOutcome(Detection.Failed) + else -> Unit + } + val versionKnown = version is VersionStep.Got || version == VersionStep.Missing + val base = when (version) { + is VersionStep.Got -> ProbeOutcome( + Detection.Done, + unraidVersion = version.unraid, + apiVersionText = version.apiText, + apiVersion = ApiVersion.parse(version.apiText), + ) + VersionStep.Missing -> ProbeOutcome(Detection.Done, versionFieldMissing = true) + else -> ProbeOutcome(Detection.Done) + } + return when (val access = probeAccess(client)) { + is AccessStep.Got -> base.copy(access = access.access) + AccessStep.Unknown -> base + AccessStep.Rejected -> ProbeOutcome(Detection.KeyRejected) + AccessStep.TransportFailed -> if (versionKnown) base else ProbeOutcome(Detection.Failed) + } +} + +private suspend fun probeVersion(client: ApolloClient): VersionStep = try { + val resp = client.query(GetApiVersionQuery()).execute() + val core = resp.data?.info?.versions?.core + val errors = resp.errors.orEmpty() + when { + core != null -> VersionStep.Got(core.unraid, core.api) + errors.isNotEmpty() -> when (classifyErrors(errors)) { + ErrorClass.KeyRejected -> VersionStep.Rejected + ErrorClass.ValidationFailed -> VersionStep.Missing + ErrorClass.Forbidden -> VersionStep.Unknown + ErrorClass.Other -> VersionStep.Failed + } + // Transport failure, or a body without data and without errors. + else -> VersionStep.Failed + } +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + VersionStep.Failed +} + +private suspend fun probeAccess(client: ApolloClient): AccessStep = try { + val resp = client.query(GetKeyAccessQuery()).execute() + val me = resp.data?.me + val errors = resp.errors.orEmpty() + when { + me != null -> AccessStep.Got( + KeyAccess.Known( + roles = me.roles.map { it.rawValue }.toSet(), + grants = me.permissions.orEmpty().flatMap { p -> + p.actions.map { "${p.resource.rawValue}:${it.rawValue}" } + }.toSet(), + ), + ) + errors.isNotEmpty() -> when (classifyErrors(errors)) { + ErrorClass.KeyRejected -> AccessStep.Rejected + // FORBIDDEN (a key without ME:READ_ANY), validation and any other error: fail open. + else -> AccessStep.Unknown + } + else -> AccessStep.TransportFailed + } +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + AccessStep.TransportFailed +} diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityRepository.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityRepository.kt new file mode 100644 index 00000000..8097556e --- /dev/null +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityRepository.kt @@ -0,0 +1,127 @@ +package io.github.nofuturekid.nova.data.repository + +import io.github.nofuturekid.nova.data.model.CapabilityTarget +import io.github.nofuturekid.nova.data.model.Detection +import io.github.nofuturekid.nova.data.model.ServerCapabilities +import io.github.nofuturekid.nova.di.ApplicationScope +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The capabilities of the active server: its API version and what its API key may do (ADR-0047). + * Built like [io.github.nofuturekid.nova.data.update.UpdateTracker]: a singleton that detects in + * the application scope, so a detection is not lost when a screen goes away, and holds the result + * in memory only (a server upgrade or a changed key would leave a stored result stale). + * + * Detection runs on exactly three occasions and no other: the TARGET changes (server id, resolved + * address or API key; a renamed server or a changed accent is not a change), [refresh] is called + * (pull-to-refresh, return to the foreground), or the app starts. There is no timer, no retry loop + * and no polling, so nothing runs while the app is in the background. + * + * Fail open, by construction: + * - a new target starts at [Detection.Detecting] with unknown access, so one server's + * "denied" never gates another server; + * - a failed detection never replaces a result that was good for the same target, so a + * transient error cannot make controls appear and disappear; + * - a result for a target that is no longer current is dropped. + */ +@Singleton +class CapabilityRepository internal constructor( + private val scope: CoroutineScope, + active: Flow, + private val targetOf: (ActiveServer?) -> CapabilityTarget?, + private val probe: suspend (ActiveServer) -> ProbeOutcome, +) { + @Inject + constructor( + servers: ServerRepository, + unraid: UnraidRepository, + @ApplicationScope scope: CoroutineScope, + ) : this( + scope = scope, + active = servers.activeWithKey, + targetOf = unraid::capabilityTarget, + probe = unraid::probeCapabilities, + ) + + private val state = MutableStateFlow(ServerCapabilities.UNKNOWN) + + /** The capabilities of the active server. Starts, and stays, unknown until a detection has answered. */ + val capabilities: StateFlow = state.asStateFlow() + + private class Running(val target: CapabilityTarget, val job: Job) + + /** Guards [currentActive], [running] and the start of a detection. */ + private val lock = Mutex() + private var currentActive: ActiveServer? = null + private var running: Running? = null + + // Last in the property list: the launched job reads every property above. + init { + scope.launch { active.collect(::onActive) } + } + + /** + * Detects again for the current target: pull-to-refresh, and the app returning to the foreground + * (the user may have widened or narrowed the key in the Unraid web UI, which keeps the same key + * string). Joins a detection that is already running for the same target; never runs two in + * parallel. The last good result stays visible while it runs. + */ + fun refresh() { + scope.launch { + lock.withLock { + val a = currentActive + val target = targetOf(a) + if (a != null && target != null) startUnlessRunning(a, target) + } + } + } + + private suspend fun onActive(a: ActiveServer?) = lock.withLock { + currentActive = a // always: a pin or mode change must reach the next probe even if the target is equal + val target = targetOf(a) + if (target == state.value.target) return@withLock + running?.job?.cancel() + running = null + if (target == null) { + state.value = ServerCapabilities.UNKNOWN + } else { + state.value = ServerCapabilities(target = target, detection = Detection.Detecting) + startUnlessRunning(a!!, target) + } + } + + /** Caller holds [lock]. */ + private fun startUnlessRunning(a: ActiveServer, target: CapabilityTarget) { + if (running?.let { it.target == target && it.job.isActive } == true) return + running = Running(target, scope.launch { detect(a, target) }) + } + + private suspend fun detect(a: ActiveServer, target: CapabilityTarget) { + val outcome = try { + probe(a) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + ProbeOutcome(Detection.Failed) + } + state.update { current -> + when { + current.target != target -> current // the target changed while this ran: drop the answer + outcome.detection == Detection.Failed && current.detection == Detection.Done -> current + else -> outcome.toCapabilities(target) + } + } + } +} diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt index 11701f5c..5f277774 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/data/repository/UnraidRepository.kt @@ -52,6 +52,7 @@ import io.github.nofuturekid.nova.data.api.toServerInfo import io.github.nofuturekid.nova.data.api.toVms import io.github.nofuturekid.nova.data.api.toIfaceSamples import io.github.nofuturekid.nova.data.model.ArrayInfo +import io.github.nofuturekid.nova.data.model.CapabilityTarget import io.github.nofuturekid.nova.data.model.ConnectionMode import io.github.nofuturekid.nova.data.model.Container import io.github.nofuturekid.nova.data.model.ContainerLiveStats @@ -1104,6 +1105,16 @@ class UnraidRepository @Inject constructor( withTimeoutOrNull(REFRESH_MAX_WAIT_MS) { refreshNudge.awaitCompleted(generation) } } + /** + * The server address and key that capabilities are detected for (ADR-0047), or null without a + * usable key. Equal targets mean the same detection result is still valid. + */ + fun capabilityTarget(active: ActiveServer?): CapabilityTarget? = + active?.takeIf { it.apiKey.isNotBlank() }?.let { CapabilityTarget(it.server.id, activeUrl(it), it.apiKey) } + + /** The two detection queries for [active], classified (ADR-0047). Read-only. */ + suspend fun probeCapabilities(active: ActiveServer): ProbeOutcome = probeCapabilities(buildClient(active)) + private fun activeUrl(active: ActiveServer): String = when (active.mode) { ConnectionMode.Local -> active.server.localUrl ConnectionMode.Remote -> active.server.remoteUrl.ifBlank { active.server.localUrl } diff --git a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt index 11799987..dd49a1e7 100644 --- a/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt +++ b/app/src/main/kotlin/io/github/nofuturekid/nova/ui/screens/main/MainViewModel.kt @@ -35,11 +35,13 @@ import io.github.nofuturekid.nova.data.model.LiveMetrics import io.github.nofuturekid.nova.data.model.NetworkThroughput import io.github.nofuturekid.nova.data.model.Notifications import io.github.nofuturekid.nova.data.model.Server +import io.github.nofuturekid.nova.data.model.ServerCapabilities import io.github.nofuturekid.nova.data.model.ServerInfo import io.github.nofuturekid.nova.data.model.Temperature import io.github.nofuturekid.nova.data.model.UpdateStatus import io.github.nofuturekid.nova.data.model.Vm import io.github.nofuturekid.nova.data.repository.ActionFailedException +import io.github.nofuturekid.nova.data.repository.CapabilityRepository import io.github.nofuturekid.nova.data.repository.DomainState import io.github.nofuturekid.nova.data.repository.ServerRepository import io.github.nofuturekid.nova.data.repository.SettingsRepository @@ -63,6 +65,7 @@ class MainViewModel @Inject constructor( private val unraid: UnraidRepository, private val settings: SettingsRepository, private val updateTracker: UpdateTracker, + private val caps: CapabilityRepository, ) : ViewModel() { // ── Resilient action seam (triage #19, ADR-0037) ────────────────── @@ -163,7 +166,20 @@ class MainViewModel @Inject constructor( fun setDockerSheetOpen(open: Boolean) { _dockerSheetOpen.value = open } private val _appVisible = MutableStateFlow(true) - fun setAppVisible(visible: Boolean) { _appVisible.value = visible } + fun setAppVisible(visible: Boolean) { + // Every ON_START, including a fresh MainViewModel after the activity was finished while the + // process lived: the user may have changed the key's permissions in the Unraid web UI (ADR-0047). + // refresh() joins a detection that is already running, so the first start does not probe twice. + if (visible) caps.refresh() + _appVisible.value = visible + } + + /** + * The API version of the active server and what its API key may do (ADR-0047). Unknown until + * detected, and unknown is never "denied". Nothing reads it yet; the screens take it up in + * follow-up changes. + */ + val capabilities: StateFlow = caps.capabilities // ── Per-domain polling state (gated, lifecycle-aware) ───────────── // @@ -336,6 +352,7 @@ class MainViewModel @Inject constructor( /** Pull-to-refresh: nudges every active stream to refetch now and * returns when the refreshed data has arrived (or failed). */ suspend fun refresh() { + caps.refresh() unraid.refreshAll() } diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/ServerCapabilitiesTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/ServerCapabilitiesTest.kt new file mode 100644 index 00000000..d1d49820 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/model/ServerCapabilitiesTest.kt @@ -0,0 +1,168 @@ +package io.github.nofuturekid.nova.data.model + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * ADR-0047. The model decides which controls a key may be offered, so every test here + * states the user-facing reason, not just the table lookup. + */ +class ServerCapabilitiesTest { + private fun withAccess(roles: Set, grants: Set = emptySet()) = + ServerCapabilities(detection = Detection.Done, access = KeyAccess.Known(roles, grants)) + + private fun ServerCapabilities.everyAction() = KeyAction.entries.associateWith { access(it) } + + // WHY: a read-only key must not be offered buttons the server will refuse (#315). + @Test fun viewerKeyMayReadButNotControl() { + assertTrue(withAccess(setOf("VIEWER")).everyAction().values.all { it == Access.Denied }) + } + + @Test fun adminKeyMayDoEverything() { + assertTrue(withAccess(setOf("ADMIN")).everyAction().values.all { it == Access.Allowed }) + } + + // WHY: the least-privilege key (VIEWER plus a few explicit permissions) must get exactly its buttons. + @Test fun explicitPermissionsAddToRole() { + val caps = withAccess(setOf("VIEWER"), setOf("DOCKER:UPDATE_ANY")) + assertEquals(Access.Allowed, caps.access(KeyAction.ControlDocker)) + assertEquals(Access.Denied, caps.access(KeyAction.ControlVms)) + assertEquals(Access.Denied, caps.access(KeyAction.DeleteNotifications)) + } + + // WHY: a key can hold permissions and no role at all; its grants alone decide. + @Test fun keyWithoutRolesUsesItsPermissions() { + val caps = withAccess(emptySet(), setOf("VMS:UPDATE_ANY")) + assertEquals(Access.Allowed, caps.access(KeyAction.ControlVms)) + assertEquals(Access.Denied, caps.access(KeyAction.ControlDocker)) + } + + // WHY: the server matches UPDATE_ANY exactly; an *_OWN grant never satisfies the guard. + @Test fun ownActionsDoNotCount() { + assertEquals(Access.Denied, withAccess(emptySet(), setOf("DOCKER:UPDATE_OWN")).access(KeyAction.ControlDocker)) + } + + // WHY: UPDATE_ANY does not imply DELETE_ANY on the server, so notifications split in two. + @Test fun updateNotificationsDoesNotAllowDelete() { + val caps = withAccess(emptySet(), setOf("NOTIFICATIONS:UPDATE_ANY")) + assertEquals(Access.Allowed, caps.access(KeyAction.UpdateNotifications)) + assertEquals(Access.Denied, caps.access(KeyAction.DeleteNotifications)) + } + + // WHY: a role a newer Unraid adds must never hide a control NOVA cannot judge. + @Test fun unknownRoleFailsOpen() { + val caps = withAccess(setOf("FUTURE_ROLE")) + assertTrue(caps.everyAction().values.all { it == Access.Unknown }) + } + + // WHY: a known role that grants it still wins over an unknown role next to it. + @Test fun knownGrantBeatsUnknownRole() { + assertEquals(Access.Allowed, withAccess(setOf("FUTURE_ROLE", "ADMIN")).access(KeyAction.ControlArray)) + } + + // WHY: "the permission query failed" is not "the key may not" (#315: the app then behaves as today). + @Test fun unknownAccessIsNeverDenied() { + assertTrue(ServerCapabilities.UNKNOWN.everyAction().values.all { it == Access.Unknown }) + assertTrue(ServerCapabilities(detection = Detection.Failed).everyAction().values.all { it == Access.Unknown }) + } + + // WHY: this pins our copy of the server's role policy (policy.ts) so drift shows up as a failing test. + @Test fun roleTableMirrorsUpstream() { + assertEquals(false, roleGrants("VIEWER", "API_KEY", "READ_ANY")) + assertEquals(true, roleGrants("VIEWER", "DOCKER", "READ_ANY")) + assertEquals(true, roleGrants("VIEWER", "ME", "READ_ANY")) + assertEquals(false, roleGrants("VIEWER", "DOCKER", "UPDATE_ANY")) + assertEquals(true, roleGrants("ADMIN", "API_KEY", "DELETE_ANY")) + assertEquals(true, roleGrants("CONNECT", "CONNECT__REMOTE_ACCESS", "UPDATE_ANY")) + assertEquals(false, roleGrants("CONNECT", "DOCKER", "UPDATE_ANY")) + assertEquals(true, roleGrants("GUEST", "ME", "READ_ANY")) + assertEquals(false, roleGrants("GUEST", "DOCKER", "READ_ANY")) + assertNull(roleGrants("FUTURE_ROLE", "DOCKER", "READ_ANY")) + } + + // WHY: a new control must not bypass hiding and the documented permission table (#315, #316). + @Test fun everyMutationHasAKeyAction() { + val dir = listOf(File("src/main/graphql"), File("app/src/main/graphql")).first { it.isDirectory } + val source = File(dir, "io/github/nofuturekid/nova/mutations.graphql").readLines() + .filterNot { it.trimStart().startsWith("#") }.joinToString("\n") + val names = Regex("""\bmutation\s+(\w+)""").findAll(source).map { it.groupValues[1] }.toList() + assertTrue("no mutations found", names.size > 20) + for (n in names) { + val owners = KeyAction.entries.filter { n in it.operations } + assertEquals("mutation $n must belong to exactly one KeyAction", 1, owners.size) + assertEquals(owners.single(), KeyAction.forOperation(n)) + } + } + + // WHY: one operation behind two permissions would make "hide" and "document" disagree. + @Test fun noOperationBelongsToTwoActions() { + val all = KeyAction.entries.flatMap { it.operations.toList() } + assertEquals(all.toSet().size, all.size) + } + + @Test fun permissionIsSpelledLikeTheServer() { + assertEquals("DOCKER:UPDATE_ANY", KeyAction.ControlDocker.permission) + assertEquals("NOTIFICATIONS:DELETE_ANY", KeyAction.DeleteNotifications.permission) + } + + // WHY: the API string carries a build suffix; the order must ignore it. + @Test fun parsesApiVersionWithBuildSuffix() { + assertEquals(ApiVersion(4, 37, 11), ApiVersion.parse("4.37.11+8e587fe2")) + assertEquals(ApiVersion(4, 36, 1), ApiVersion.parse("4.36.1+f3205d97")) + assertEquals(ApiVersion(4, 37, 11), ApiVersion.parse("4.37.11-2")) + assertNull(ApiVersion.parse("")) + assertNull(ApiVersion.parse(null)) + assertNull(ApiVersion.parse("unknown")) + assertNull(ApiVersion.parse("4.37")) + } + + // WHY: 4.9.0 is older than 4.30.0 although "9" sorts after "30" as text. + @Test fun versionsCompareByNumber() { + assertTrue(ApiVersion(4, 9, 0) < ApiVersion(4, 30, 0)) + assertTrue(ApiVersion(4, 36, 0) > ApiVersion(4, 35, 9)) + assertTrue(ApiVersion(5, 0, 0) > ApiVersion(4, 99, 99)) + } + + private fun caps(version: String?, fieldMissing: Boolean = false) = ServerCapabilities( + detection = Detection.Done, apiVersion = ApiVersion.parse(version), versionFieldMissing = fieldMissing, + ) + + // WHY: sending docker.restart to a 4.35 server fails validation; unknown must not claim support. + @Test fun restartNeeds436() { + assertEquals(Support.No, caps("4.35.1").supports(ApiFeature.DockerRestart)) + assertEquals(Support.Yes, caps("4.36.0").supports(ApiFeature.DockerRestart)) + assertEquals(Support.Yes, caps("4.37.11+8e587fe2").supports(ApiFeature.DockerRestart)) + assertEquals(Support.Unknown, caps(null).supports(ApiFeature.DockerRestart)) + } + + @Test fun floorsAreTheDocumentedOnes() { + assertEquals(ApiVersion(4, 30, 0), ApiFeature.Base.since) + assertEquals(ApiVersion(4, 35, 0), ApiFeature.NetworkSpeed.since) + assertEquals(ApiVersion(4, 36, 0), ApiFeature.DockerRestart.since) + assertEquals(Support.No, caps("4.29.2").supports(ApiFeature.Base)) + assertEquals(Support.Yes, caps("4.30.0").supports(ApiFeature.Base)) + assertEquals(Support.No, caps("4.34.0").supports(ApiFeature.NetworkSpeed)) + assertEquals(Support.Yes, caps("4.35.0").supports(ApiFeature.NetworkSpeed)) + } + + // WHY: "too old" is claimed only when the server proved it; unknown must not show an "API too old" hint. + @Test fun apiTooOldNeedsProof() { + assertTrue(caps("4.29.2").apiTooOld) + assertTrue(caps(null, fieldMissing = true).apiTooOld) + assertFalse(caps(null).apiTooOld) + assertFalse(ServerCapabilities.UNKNOWN.apiTooOld) + assertFalse(caps("4.30.0").apiTooOld) + } + + // WHY: the key must never reach a log line through the target. + @Test fun targetNeverPrintsTheKey() { + val t = CapabilityTarget("s1", "http://tower", "secret-key-123") + assertFalse(t.toString().contains("secret-key-123")) + assertEquals(t, CapabilityTarget("s1", "http://tower", "secret-key-123")) + assertTrue(t != CapabilityTarget("s1", "http://tower", "other-key")) + } +} diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityProbeTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityProbeTest.kt new file mode 100644 index 00000000..87751705 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityProbeTest.kt @@ -0,0 +1,209 @@ +package io.github.nofuturekid.nova.data.repository + +import com.apollographql.apollo.ApolloClient +import com.apollographql.apollo.api.http.HttpRequest +import com.apollographql.apollo.api.http.HttpResponse +import com.apollographql.apollo.network.http.HttpEngine +import io.github.nofuturekid.nova.data.model.Access +import io.github.nofuturekid.nova.data.model.ApiVersion +import io.github.nofuturekid.nova.data.model.Detection +import io.github.nofuturekid.nova.data.model.KeyAccess +import io.github.nofuturekid.nova.data.model.KeyAction +import io.github.nofuturekid.nova.data.model.Support +import io.github.nofuturekid.nova.data.model.ApiFeature +import kotlinx.coroutines.test.runTest +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * ADR-0047. versionOk, viewerMe, adminMe and unauthenticated are copied verbatim from real answers + * of an Unraid 7.4.0 server (API 4.37.11) recorded on 2026-11-13; forbidden and validation400 keep + * the real shape with the path or field adapted to these operations; resolverError and the + * explicit-permission and FUTURE_ROLE bodies are constructed. They run on a scripted HTTP engine, i.e. through the real Apollo response path. + */ +class CapabilityProbeTest { + + private class Engine( + private val version: () -> HttpResponse, + private val access: () -> HttpResponse, + ) : HttpEngine { + val operations = mutableListOf() + override suspend fun execute(request: HttpRequest): HttpResponse { + val body = Buffer().also { request.body?.writeTo(it) }.readUtf8() + return when { + "GetApiVersion" in body -> { operations += "GetApiVersion"; version() } + "GetKeyAccess" in body -> { operations += "GetKeyAccess"; access() } + else -> error("unexpected operation: $body") + } + } + override fun close() {} + } + + private fun reply(body: String, status: Int = 200, type: String = "application/json"): () -> HttpResponse = { + HttpResponse.Builder(status).addHeader("Content-Type", type).body(Buffer().writeUtf8(body)).build() + } + + private val versionOk = reply("""{"data":{"info":{"versions":{"core":{"unraid":"7.4.0","api":"4.37.11+8e587fe2"}}}}}""") + private val viewerMe = reply("""{"data":{"me":{"roles":["VIEWER"],"permissions":[]}}}""") + private val adminMe = reply("""{"data":{"me":{"roles":["ADMIN"],"permissions":[]}}}""") + private val unauthenticated = reply( + """{"errors":[{"message":"API key validation failed","locations":[{"line":1,"column":3}],"path":["me"],"extensions":{"code":"UNAUTHENTICATED","originalError":{"message":"API key validation failed","error":"Unauthorized","statusCode":401}}}],"data":null}""", + ) + private val forbidden = reply( + """{"errors":[{"message":"Forbidden resource","locations":[{"line":1,"column":3}],"path":["me"],"extensions":{"code":"FORBIDDEN","originalError":{"message":"Forbidden resource","error":"Forbidden","statusCode":403}}}],"data":null}""", + ) + private val validation400 = reply( + """{"errors":[{"message":"Cannot query field \"core\" on type \"InfoVersions\".","locations":[{"line":1,"column":12}],"extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}""", + status = 400, + type = "application/graphql-response+json", + ) + private val resolverError = reply( + """{"errors":[{"message":"boom","extensions":{"code":"INTERNAL_SERVER_ERROR"}}],"data":null}""", + ) + private val unreachable: () -> HttpResponse = { throw IOException("connection refused") } + + private fun client(engine: HttpEngine) = + ApolloClient.Builder().serverUrl("http://unraid.test/graphql").httpEngine(engine).build() + + private suspend fun probe(version: () -> HttpResponse, access: () -> HttpResponse): Pair { + val engine = Engine(version, access) + return probeCapabilities(client(engine)) to engine + } + + // WHY: #315 — a read-only key reads everything and must not be offered buttons. + @Test fun viewerKey_isDetected_withVersion() = runTest { + val (out, _) = probe(versionOk, viewerMe) + assertEquals(Detection.Done, out.detection) + assertEquals("7.4.0", out.unraidVersion) + assertEquals("4.37.11+8e587fe2", out.apiVersionText) + assertEquals(ApiVersion(4, 37, 11), out.apiVersion) + assertEquals(KeyAccess.Known(setOf("VIEWER"), emptySet()), out.access) + val caps = out.toCapabilities(null) + assertTrue(KeyAction.entries.all { caps.access(it) == Access.Denied }) + assertEquals(Support.Yes, caps.supports(ApiFeature.DockerRestart)) + } + + @Test fun adminKey_isDetected() = runTest { + val (out, _) = probe(versionOk, adminMe) + assertTrue(KeyAction.entries.all { out.toCapabilities(null).access(it) == Access.Allowed }) + } + + // WHY: explicit permissions are reported as RESOURCE:ACTION, the same spelling KeyAction uses. + @Test fun explicitPermissions_becomeGrants() = runTest { + val me = reply("""{"data":{"me":{"roles":["VIEWER"],"permissions":[{"resource":"DOCKER","actions":["UPDATE_ANY","READ_ANY"]},{"resource":"VMS","actions":["UPDATE_OWN"]}]}}}""") + val (out, _) = probe(versionOk, me) + assertEquals( + KeyAccess.Known(setOf("VIEWER"), setOf("DOCKER:UPDATE_ANY", "DOCKER:READ_ANY", "VMS:UPDATE_OWN")), + out.access, + ) + } + + // WHY: a role a newer Unraid adds must stay "unknown", never "denied". Apollo hands an enum value that + // is not in the client schema over as UNKNOWN__ (the raw name is not kept), which the role table + // does not know either, so the answer is Unknown. + @Test fun futureRole_staysUnknown() = runTest { + val me = reply("""{"data":{"me":{"roles":["FUTURE_ROLE"],"permissions":[]}}}""") + val (out, _) = probe(versionOk, me) + assertEquals(Detection.Done, out.detection) + assertEquals(KeyAccess.Known(setOf("UNKNOWN__"), emptySet()), out.access) + assertTrue(KeyAction.entries.all { out.toCapabilities(null).access(it) == Access.Unknown }) + } + + // WHY: the same, next to a known role that grants nothing: still "unknown", not "denied". + @Test fun futureRoleNextToViewer_staysUnknown() = runTest { + val me = reply("""{"data":{"me":{"roles":["VIEWER","FUTURE_ROLE"],"permissions":[]}}}""") + val (out, _) = probe(versionOk, me) + assertTrue(KeyAction.entries.all { out.toCapabilities(null).access(it) == Access.Unknown }) + } + + // WHY: #315 "If the permission query fails, the app behaves as today": a refused `me` hides nothing. + @Test fun forbiddenMe_failsOpen_andKeepsTheVersion() = runTest { + val (out, _) = probe(versionOk, forbidden) + assertEquals(Detection.Done, out.detection) + assertEquals(KeyAccess.Unknown, out.access) + assertEquals(ApiVersion(4, 37, 11), out.apiVersion) + assertTrue(KeyAction.entries.all { out.toCapabilities(null).access(it) == Access.Unknown }) + } + + @Test fun otherErrorOnMe_failsOpen() = runTest { + val (out, _) = probe(versionOk, resolverError) + assertEquals(Detection.Done, out.detection) + assertEquals(KeyAccess.Unknown, out.access) + } + + // WHY: a wrong key must be reported as rejected, and only because the server said so. + @Test fun unauthenticated_isKeyRejected_andSkipsTheSecondQuery() = runTest { + val (out, engine) = probe(unauthenticated, viewerMe) + assertEquals(Detection.KeyRejected, out.detection) + assertEquals(KeyAccess.Unknown, out.access) + assertEquals(listOf("GetApiVersion"), engine.operations) + } + + @Test fun unauthenticatedOnMe_isKeyRejected() = runTest { + val (out, _) = probe(versionOk, unauthenticated) + assertEquals(Detection.KeyRejected, out.detection) + } + + // WHY: #247 — an unknown field is HTTP 400 with a validation error; it means an API older than 4.15.0. + @Test fun versionValidationError_meansTheApiIsTooOld() = runTest { + val (out, _) = probe(validation400, validation400) + assertEquals(Detection.Done, out.detection) + assertTrue(out.versionFieldMissing) + val caps = out.toCapabilities(null) + assertTrue(caps.apiTooOld) + assertEquals(Support.No, caps.supports(ApiFeature.DockerRestart)) + assertEquals(KeyAccess.Unknown, out.access) + } + + // WHY: a role-less key without INFO is FORBIDDEN on the version; we still learn what we can from `me`. + @Test fun forbiddenVersion_isUnknown_notTooOld() = runTest { + val (out, engine) = probe(forbidden, viewerMe) + assertEquals(Detection.Done, out.detection) + assertEquals(null, out.apiVersion) + assertEquals(false, out.versionFieldMissing) + assertEquals(false, out.toCapabilities(null).apiTooOld) + assertEquals(KeyAccess.Known(setOf("VIEWER"), emptySet()), out.access) + assertEquals(listOf("GetApiVersion", "GetKeyAccess"), engine.operations) + } + + // WHY: an unreachable server is "could not detect", not "key rejected" and not "denied". + @Test fun transportFailure_isFailed_andHidesNothing() = runTest { + val (out, engine) = probe(unreachable, unreachable) + assertEquals(Detection.Failed, out.detection) + assertEquals(listOf("GetApiVersion"), engine.operations) + val caps = out.toCapabilities(null) + assertTrue(KeyAction.entries.all { caps.access(it) == Access.Unknown }) + assertEquals(false, caps.apiTooOld) + } + + @Test fun serverErrorOnVersion_isFailed() = runTest { + assertEquals(Detection.Failed, probe(resolverError, viewerMe).first.detection) + } + + // WHY: the version is worth keeping when only the second query fails. + @Test fun transportFailureOnMe_keepsTheVersion() = runTest { + val (out, _) = probe(versionOk, unreachable) + assertEquals(Detection.Done, out.detection) + assertEquals(ApiVersion(4, 37, 11), out.apiVersion) + assertEquals(KeyAccess.Unknown, out.access) + } + + // WHY: a body that is not GraphQL (a proxy, a captive portal) must not be taken for a verdict. + @Test fun nonGraphqlHttpError_isFailed() = runTest { + val (out, _) = probe(reply("nope", status = 502, type = "text/html"), viewerMe) + assertEquals(Detection.Failed, out.detection) + } + + @Test fun classifyErrors_firstMatchWins() { + fun err(code: String?, msg: String = "x") = com.apollographql.apollo.api.Error.Builder(msg) + .apply { if (code != null) extensions(mapOf("code" to code)) }.build() + assertEquals(ErrorClass.KeyRejected, classifyErrors(listOf(err("FORBIDDEN"), err("UNAUTHENTICATED")))) + assertEquals(ErrorClass.ValidationFailed, classifyErrors(listOf(err(null, "Cannot query field \"a\" on type \"B\".")))) + assertEquals(ErrorClass.ValidationFailed, classifyErrors(listOf(err("GRAPHQL_VALIDATION_FAILED")))) + assertEquals(ErrorClass.Forbidden, classifyErrors(listOf(err("FORBIDDEN")))) + assertEquals(ErrorClass.Other, classifyErrors(listOf(err("INTERNAL_SERVER_ERROR")))) + } +} diff --git a/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityRepositoryTest.kt b/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityRepositoryTest.kt new file mode 100644 index 00000000..79bffac1 --- /dev/null +++ b/app/src/test/kotlin/io/github/nofuturekid/nova/data/repository/CapabilityRepositoryTest.kt @@ -0,0 +1,272 @@ +package io.github.nofuturekid.nova.data.repository + +import io.github.nofuturekid.nova.data.local.ApiKeyResult +import io.github.nofuturekid.nova.data.model.Access +import io.github.nofuturekid.nova.data.model.ApiVersion +import io.github.nofuturekid.nova.data.model.CapabilityTarget +import io.github.nofuturekid.nova.data.model.ConnectionMode +import io.github.nofuturekid.nova.data.model.Detection +import io.github.nofuturekid.nova.data.model.KeyAccess +import io.github.nofuturekid.nova.data.model.KeyAction +import io.github.nofuturekid.nova.data.model.Server +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Everything here runs at one point of virtual time (there are no delays to wait for), so + * `runCurrent()` settles it. `advanceUntilIdle()` would skip the repository's work: it runs in + * `backgroundScope`, which that call deliberately ignores. + * + * ADR-0047. These tests pin WHEN detection runs (target change, refresh, start; never on a timer) + * and that a failure or a stale answer can never change what the user sees. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CapabilityRepositoryTest { + + private fun active(id: String = "s1", name: String = "Tower", url: String = "http://tower", key: String = "key-1") = + ActiveServer( + server = Server(id = id, name = name, hostname = name, localUrl = url, remoteUrl = ""), + mode = ConnectionMode.Local, + apiKey = key, + keyState = if (key.isBlank()) ApiKeyResult.Absent else ApiKeyResult.Present(key), + ) + + private val targetOf: (ActiveServer?) -> CapabilityTarget? = { a -> + a?.takeIf { it.apiKey.isNotBlank() }?.let { CapabilityTarget(it.server.id, it.server.localUrl, it.apiKey) } + } + + private val viewer = ProbeOutcome( + Detection.Done, "7.4.0", "4.37.11+8e587fe2", ApiVersion(4, 37, 11), + access = KeyAccess.Known(setOf("VIEWER"), emptySet()), + ) + private val admin = viewer.copy(access = KeyAccess.Known(setOf("ADMIN"), emptySet())) + private val failed = ProbeOutcome(Detection.Failed) + + /** Probe that answers from a queue (the last answer repeats) and counts its calls. */ + private class Script(vararg answers: suspend () -> ProbeOutcome) { + private val queue = answers.toList() + var calls = 0 + val seen = mutableListOf() + suspend fun run(a: ActiveServer): ProbeOutcome { + seen += a + val answer = queue[minOf(calls, queue.size - 1)] + calls++ + return answer() + } + } + + private fun answer(o: ProbeOutcome): suspend () -> ProbeOutcome = { o } + + private fun TestScope.repo(source: MutableStateFlow, script: Script) = + CapabilityRepository(backgroundScope, source, targetOf, script::run) + + // WHY: before any detection the answer is unknown, which shows the controls as today. + @Test fun startsUnknown_andWithoutKeyNeverProbes() = runTest { + val script = Script(answer(viewer)) + val r = repo(MutableStateFlow(active(key = "")), script) + runCurrent() + assertEquals(Detection.Idle, r.capabilities.value.detection) + assertNull(r.capabilities.value.target) + assertEquals(0, script.calls) + } + + @Test fun detectsOnceForTheActiveServer() = runTest { + val script = Script(answer(viewer)) + val r = repo(MutableStateFlow(active()), script) + runCurrent() + assertEquals(1, script.calls) + assertEquals(Detection.Done, r.capabilities.value.detection) + assertEquals(Access.Denied, r.capabilities.value.access(KeyAction.ControlDocker)) + } + + // WHY: one server's "denied" must never gate another server while the second one is still unknown. + @Test fun newTargetResetsToUnknownFirst() = runTest { + val gate = CompletableDeferred() + val source = MutableStateFlow(active("a", key = "ka")) + val r = repo(source, Script(answer(viewer), { gate.await() })) + runCurrent() + assertEquals(Access.Denied, r.capabilities.value.access(KeyAction.ControlDocker)) + + source.value = active("b", url = "http://other", key = "kb") + runCurrent() + val now = r.capabilities.value + assertEquals(Detection.Detecting, now.detection) + assertEquals("b", now.target?.serverId) + assertTrue(KeyAction.entries.all { now.access(it) == Access.Unknown }) + + gate.complete(admin) + runCurrent() + assertEquals(Access.Allowed, r.capabilities.value.access(KeyAction.ControlDocker)) + } + + // WHY: only server, address or key start a detection; a rename or accent change is not worth two requests. + @Test fun unrelatedServerChangeDoesNotReprobe() = runTest { + val script = Script(answer(viewer)) + val source = MutableStateFlow(active(name = "Tower")) + repo(source, script) + runCurrent() + source.value = active(name = "Renamed tower") + runCurrent() + assertEquals(1, script.calls) + } + + @Test fun changedKeyOrAddressReprobes() = runTest { + val script = Script(answer(viewer)) + val source = MutableStateFlow(active()) + repo(source, script) + runCurrent() + source.value = active(key = "key-2") + runCurrent() + source.value = active(key = "key-2", url = "http://tower.example") + runCurrent() + assertEquals(3, script.calls) + } + + // WHY: pull-to-refresh and foreground can arrive together; never two detections in parallel. + @Test fun refreshJoinsRunningDetection() = runTest { + val gate = CompletableDeferred() + val script = Script({ gate.await() }) + val r = repo(MutableStateFlow(active()), script) + runCurrent() + r.refresh() + r.refresh() + runCurrent() + assertEquals(1, script.calls) + gate.complete(viewer) + runCurrent() + assertEquals(1, script.calls) + assertEquals(Detection.Done, r.capabilities.value.detection) + } + + // WHY: the user widens the key in the web UI (same key string); refresh must pick that up. + @Test fun refreshAfterDone_detectsAgain_andShowsTheNewAccess() = runTest { + val script = Script(answer(viewer), answer(admin)) + val r = repo(MutableStateFlow(active()), script) + runCurrent() + assertEquals(Access.Denied, r.capabilities.value.access(KeyAction.ControlVms)) + r.refresh() + runCurrent() + assertEquals(2, script.calls) + assertEquals(Access.Allowed, r.capabilities.value.access(KeyAction.ControlVms)) + } + + // WHY: while a re-detection runs, the last good result stays (no flicker of buttons). + @Test fun lastResultStaysVisibleWhileRedetecting() = runTest { + val gate = CompletableDeferred() + val script = Script(answer(viewer), { gate.await() }) + val r = repo(MutableStateFlow(active()), script) + runCurrent() + r.refresh() + runCurrent() + assertEquals(Detection.Done, r.capabilities.value.detection) + assertEquals(Access.Denied, r.capabilities.value.access(KeyAction.ControlDocker)) + gate.complete(viewer) + } + + // WHY: a transient error must not toggle controls. + @Test fun failedRedetectionKeepsLastResult() = runTest { + val script = Script(answer(viewer), answer(failed)) + val r = repo(MutableStateFlow(active()), script) + runCurrent() + val before = r.capabilities.value + r.refresh() + runCurrent() + assertEquals(2, script.calls) + assertEquals(before, r.capabilities.value) + } + + // WHY: a first detection that fails is "unknown", so the UI fails open. + @Test fun failedFirstDetection_isFailed_andUnknown() = runTest { + val r = repo(MutableStateFlow(active()), Script(answer(failed))) + runCurrent() + val caps = r.capabilities.value + assertEquals(Detection.Failed, caps.detection) + assertTrue(KeyAction.entries.all { caps.access(it) == Access.Unknown }) + } + + @Test fun probeThatThrows_isFailed_notACrash() = runTest { + val r = repo(MutableStateFlow(active()), Script({ throw IllegalStateException("boom") })) + runCurrent() + assertEquals(Detection.Failed, r.capabilities.value.detection) + } + + // WHY: the server said UNAUTHENTICATED; the next detection with a fixed key must clear it. + @Test fun keyRejected_thenAccepted() = runTest { + val script = Script(answer(ProbeOutcome(Detection.KeyRejected)), answer(viewer)) + val r = repo(MutableStateFlow(active()), script) + runCurrent() + assertEquals(Detection.KeyRejected, r.capabilities.value.detection) + r.refresh() + runCurrent() + assertEquals(Detection.Done, r.capabilities.value.detection) + } + + // WHY: an answer for a server the user has already left must not land on the new one. + @Test fun staleAnswerIsDropped() = runTest { + val gateA = CompletableDeferred() + val script = Script({ gateA.await() }, answer(viewer)) + val source = MutableStateFlow(active("a", key = "ka")) + val r = repo(source, script) + runCurrent() + source.value = active("b", url = "http://other", key = "kb") + runCurrent() + gateA.complete(admin) // A answers late + runCurrent() + assertEquals("b", r.capabilities.value.target?.serverId) + assertEquals(Access.Denied, r.capabilities.value.access(KeyAction.ControlDocker)) + } + + @Test fun removingTheKeyResetsToUnknown() = runTest { + val source = MutableStateFlow(active()) + val r = repo(source, Script(answer(viewer))) + runCurrent() + source.value = active(key = "") + runCurrent() + assertEquals(Detection.Idle, r.capabilities.value.detection) + assertTrue(KeyAction.entries.all { r.capabilities.value.access(it) == Access.Unknown }) + } + + // WHY: the probe must use the newest server settings (e.g. a certificate pin accepted after a failed + // first detection) although the target is unchanged. + @Test fun refreshUsesTheNewestServerSettings() = runTest { + val script = Script(answer(failed), answer(viewer)) + val source = MutableStateFlow(active()) + val r = repo(source, script) + runCurrent() + source.value = active().copy(localCertPin = "pinned") + runCurrent() + assertEquals(1, script.calls) + r.refresh() + runCurrent() + assertEquals("pinned", script.seen.last().localCertPin) + } + + // WHY: no new background work (the maintainer's rule): nothing re-detects by itself. + @Test fun noProbeWithoutTrigger() = runTest { + val script = Script(answer(viewer)) + repo(MutableStateFlow(active()), script) + runCurrent() + advanceTimeBy(60 * 60 * 1000L) + runCurrent() + assertEquals(1, script.calls) + } + + // WHY: a failed detection must not turn into a retry loop either. + @Test fun noRetryAfterFailure() = runTest { + val script = Script(answer(failed)) + repo(MutableStateFlow(active()), script) + runCurrent() + advanceTimeBy(60 * 60 * 1000L) + runCurrent() + assertEquals(1, script.calls) + } +} diff --git a/docs/adr/0047-server-capabilities.md b/docs/adr/0047-server-capabilities.md new file mode 100644 index 00000000..e1ca97f9 --- /dev/null +++ b/docs/adr/0047-server-capabilities.md @@ -0,0 +1,53 @@ +# ADR-0047: Detect the server's API version and what the API key may do + +- **Status**: Accepted (2026-11-13) +- **Date**: 2026-11-13 +- **Tags**: data, api, architecture + +## Context + +NOVA talks to the Unraid GraphQL API with one API key per server. Two facts about that API were not visible to the app so far: + +- **The API version.** NOVA needs API 4.30.0 or newer (4.35.0 for live network speed, 4.36.0 for `docker.restart`). An older server answers a query with an HTTP 400 validation error, which the user saw as a raw error text (#247). +- **What the key may do.** A key with the `VIEWER` role reads everything NOVA shows but every action is refused with `FORBIDDEN` (#315). The app offered the buttons anyway. + +What was verified against a real server (Unraid 7.4.0, API `4.37.11+8e587fe2`, read-only `VIEWER` key, 2026-11-13) and against the upstream source: + +- `info { versions { core { api } } }` returns the API version with a build suffix (`4.37.11+8e587fe2`); the field exists since API 4.15.0. `me { roles permissions { resource actions } }` returns `{"roles":["VIEWER"],"permissions":[]}`: `me` describes the API key, not a signed-in user, and `permissions` lists only explicit permissions. +- The role policy of the API (`ADMIN` everything, `VIEWER` read on everything except `API_KEY`, `CONNECT`, `GUEST`) is identical from 4.30.0 to 4.37.11. The server compares actions exactly: `UPDATE_ANY` does not imply `READ_ANY`, and `*_OWN` never satisfies an `*_ANY` check. +- `me` is non-null: a `FORBIDDEN` on it nulls the whole response, so it must not share a request with the version. +- A wrong key gets `UNAUTHENTICATED` with HTTP 200 on every query except `__typename`, which is what the existing `Ping` sends. An unknown field gets HTTP 400 with `GRAPHQL_VALIDATION_FAILED`. +- Introspection is blocked on a stock 7.4.0 server (sandbox off), so the schema cannot be asked what it supports. + +## Decision + +Detect two things per server, with two small queries, keep the result in memory, and make it available as state. This change adds the foundation only; no screen reads it yet. + +- **Model** (`data/model/ServerCapabilities.kt`, pure Kotlin): `ServerCapabilities` holds the API version (parsed; a build suffix is ignored when comparing), the key's roles and explicit grants, and a `Detection` state. `access(KeyAction)` answers `Allowed`, `Denied` or `Unknown` from the static role table plus the explicit grants; `supports(ApiFeature)` answers `Yes`, `No` or `Unknown` against the floors 4.30.0, 4.35.0 and 4.36.0. `KeyAction` is the single list of controls NOVA offers and the permission the server checks for each; a unit test requires that every mutation in `mutations.graphql` belongs to exactly one, so a new control cannot slip past hiding or the documentation. +- **Detection** (`GetApiVersion`, `GetKeyAccess` in `queries.graphql`): two operations on purpose, so a refused `me` does not lose the version. `CapabilityProbe.kt` sends them and classifies the answers by `extensions.code`: `UNAUTHENTICATED` is a rejected key, `GRAPHQL_VALIDATION_FAILED` on the version means an API older than 4.15.0, `FORBIDDEN` leaves the access unknown, transport and other errors are a failed detection. The mapping lives in its own file, not in `GraphQlMapper.kt`. +- **Wiring** (`CapabilityRepository`, a singleton built like `UpdateTracker`; `MainViewModel.capabilities`): detection runs when the target (server id, resolved address, API key) changes, on pull-to-refresh, and when the app returns to the foreground. There is no timer, no retry loop and no polling, so nothing runs in the background. +- **Fail open.** Unknown is never denied. A detection that fails, a `me` that is refused and a role NOVA does not know all leave the access `Unknown`; a later UI shows controls unless the access is `Denied`, which only a successful detection can say. A failed re-detection keeps the last good result for the same target; a new target starts unknown, so one server's result never gates another. +- **Memory only.** Not stored in DataStore or in `Server`: a server upgrade or a changed key would leave a stored result stale. + +Domain availability (is Docker or the VM service turned on, does the server have an array) is deliberately not part of this model. It would cost more queries, goes stale when the user switches a service on, and each tab's own query answers it on every poll anyway. + +## Consequences + +- **Positive** — one place that knows the API version and the key's access; later changes (hiding controls a key cannot use, the atomic container restart, the Add server test, per-tab error reasons) read it instead of each probing the server. No visible change in this step. +- **Negative / trade-offs** — two more small requests per server change, pull-to-refresh and return to the foreground. The static role table copies the server's policy and can drift; an unknown role fails open, so drift can only show a button the server then refuses, never hide one the key may use (exception: a role that loses rights it has today). The CONNECT role is mirrored from the upstream policy but NOVA's detection of it is unverified on a live server. +- **Deviation from the first design, on purpose.** The detection pipeline lives in the application scope for as long as the repository exists, instead of being shared only while a screen collects it. The return to the foreground arrives together with the screen's own re-subscription, so a refresh that depended on an active collector could be lost. The foreground refresh fires on every start of the main screen; it joins a detection that is already running, so the first start does not probe twice. +- **Trigger to revisit** — if the Unraid API exposes a supported way to ask for the key's effective permissions without `PERMISSION:READ_ANY`, replace the static role table; if the role policy changes upstream, update the table and the test that pins it; if a feature must be gated that has no version floor, revisit the "no introspection" rule once introspection is allowed by default. + +## Alternatives considered + +- **Introspection of the schema.** Would tell exactly what the server has, but a stock Unraid 7.4.0 blocks it unless the developer sandbox is on, and 4.37.8 and newer also block `__type`. Version floors work everywhere. +- **Probing with a mutation to see whether it is refused.** Rejected: a probe that can change a server is not acceptable, and the server's `FORBIDDEN` for a real action is shown to the user anyway. +- **One query for version and access.** Rejected: `me` is non-null, so a key without `ME:READ_ANY` would lose the version as well. +- **`getPermissionsForRoles` from the API.** Needs `PERMISSION:READ_ANY`, which a read-only key does not have. The client computes the effective rights from the role table instead. +- **Detecting on a timer.** Rejected: nothing in this design runs in the background; the three triggers cover every real change (a key's permissions change only when the user edits them in the web UI, then returns to NOVA). +- **Storing the result.** Rejected, see Decision. + +## References + +- Issues #315 (read-only keys), #247 (older API versions), #317 (container restart), #316 (documentation of key permissions). +- Related: ADR-0035 (API key storage), `UpdateTracker` (same in-memory singleton pattern). diff --git a/docs/adr/README.md b/docs/adr/README.md index 4d60734c..7a658e70 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -81,4 +81,5 @@ ADRs live under `docs/` so the CI docs-only bypass applies — pure-ADR PRs comp | [0044](./0044-container-detail-live-stats-overlay.md) | Surface live CPU/Mem/Net/Disk in the container detail sheet from the docker stats overlay | Accepted | | [0045](./0045-array-disk-display.md) | Array disk display — sleeping disks, per-disk thresholds, errors | Accepted (updated beta10 — 2026-05-30) | | [0046](./0046-update-via-browser-download.md) | Offer updates of the GitHub build as a browser download, without an in-app installer | Accepted | +| [0047](./0047-server-capabilities.md) | Detect the server's API version and what the API key may do | Accepted | | [0049](./0049-google-play-third-distribution-channel.md) | Google Play as a third distribution channel | Accepted |