12 Commits

Author SHA1 Message Date
f3da6ea618 Merge pull request 'ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main' (#28) from chore/sync-project-tree-ci into main
All checks were successful
sync-project-tree / sync (push) Successful in 13s
SonarQube / analysis (push) Successful in 3m16s
Release APK / release (push) Successful in 9m9s
Reviewed-on: #28
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:24:49 +00:00
ae170670d9 ci(docs): auto-sync PROJECT_TREE.md to the docs repo on push to main
All checks were successful
PR Checks / android-build (pull_request) Successful in 2m23s
Add a sync-project-tree workflow that regenerates this repo's tracked-file
tree and opens (or force-updates) a PR against RunicGateway/docs whenever the
layout on main changes. Never writes to the docs repo's main directly. Reuses
the existing REGISTRY_USER / REGISTRY_TOKEN secrets. Tree rendering lives in
.gitea/scripts/gen_tree.py (deterministic, dirs-first ordering).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 16:22:06 -05:00
7fc497a1a4 Merge pull request 'test(coverage): raise unit coverage past the 50% gate (phases 0-2)' (#27) from test/coverage-phase-0-1-2 into main
Some checks failed
SonarQube / analysis (push) Has been cancelled
Reviewed-on: #27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 21:11:54 +00:00
4e3bb914ff test(coverage): raise unit coverage past the 50% gate (phases 0-2)
All checks were successful
PR Checks / android-build (pull_request) Successful in 7m19s
Executes COVERAGE_PLAN.md phases 0-2 to clear the SonarQube new-code coverage
gate (was 16.4%, threshold 50%). Estimated new-code coverage after this change
is ~57%. 109 new tests across 19 files; full suite is 264 tests, all green.

Phase 0 — coverage exclusions (sonar-project.properties): drop code a JVM unit
test can't execute from the *coverage* denominator (still analysed for
bugs/smells) — pure-@Composable UI the `*Screen.kt` glob missed
(ui/components/**, BlockRenderer, ShardComponents), Android-framework glue
(push services, Keystore-backed Encrypted* stores, Hilt di/**).

Phase 1 — DTO serialization tests: AdminDto, PublicDto, WikiDto, PostDto/PageDto/
ContactDto, SsoDto, the shard board DTOs and player game-data DTOs, and the
mobile-auth request bodies — decode + encode + computed helpers
(isPublished/isMaintenance/ActorDto.label/ShardStatusDto.isOnline).

Phase 2 — ViewModel tests: a MainDispatcherRule harness + hand-written API fakes
(FakePublicApi/FakeAdminApi/FakePlayerShardApi/FakeShardStream) drive real
repositories into the ViewModels. Covers the admin (dashboard/content/moderation/
support), content (news/post/page/wiki/home/contact), player (characters/
vendors/character/my-houses) and shard-board (champs/guilds/governors/houses/
hub) ViewModels — load success/error, form validation, role/status-aware
feedback, and live-frame merging.

To make the shard boards testable, extract a small `ShardStream` interface from
`ShardStreamClient` (bound in NetworkModule) so `ShardRepository` depends on the
capability, not the OkHttp client — lets a fake stream replace the perpetual SSE
reconnect loop in tests. No production behaviour change.

Phases 3 (repositories) and 4 (core net/auth top-up) are follow-ups; the
deep-dependency auth family (Login/Account/TrustedDevices ViewModels,
AuthRepository) lands with them. See docs/android/COVERAGE_PLAN.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 16:04:02 -05:00
efe14d3828 Merge pull request 'chore(sonar): wire JaCoCo coverage and clear actionable smells' (#26) from chore/sonar-coverage-and-cleanup into main
All checks were successful
SonarQube / analysis (push) Successful in 9m15s
Reviewed-on: #26
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 19:11:01 +00:00
43215b49a0 chore(sonar): wire JaCoCo coverage and clear actionable smells
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m44s
Fix the SonarQube coverage gate (0% on new code) — a reporting gap, not a
testing gap: the JVM unit suite already exists but the source-only scan
never received a coverage report.

- app/build.gradle.kts: apply jacoco, enable debug unit-test coverage, add a
  jacocoTestReport task (excludes generated/Hilt/Compose-singleton classes)
- sonar-project.properties: consume the JaCoCo XML; exclude pure-@Composable
  UI from coverage (JVM unit tests can't execute composable bodies)
- .gitea/workflows/sonarqube.yml: run JDK 17 + Android SDK +
  `testDebugUnitTest jacocoTestReport` before the scan

Also clear the three actionable code smells: remove an unused import
(AdminContentScreen), remove an unused parameter (AdminSupportScreen.
RespondDialog), and decompose LoginViewModel.submit() (cognitive complexity
20 -> under 15). The remaining 12 smells (snake_case DTO fields that mirror
the JSON wire contract; Compose/nav complexity) are marked Won't Fix in
SonarQube with rationale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-22 13:58:52 -05:00
a6446b04d8 Merge pull request 'fix(notifications): always serialize streams so clearing the last subscription saves' (#25) from fix/notifications-empty-subscriptions into main
All checks were successful
SonarQube / analysis (push) Successful in 1m24s
Release APK / release (push) Successful in 16m55s
Reviewed-on: #25
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 16:48:06 +00:00
9c52a3dafa fix(notifications): always serialize streams so clearing the last subscription saves
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m34s
Turning off the final notification subscription (going from one opted-in
stream to zero) failed with "could not save" and the toggle stuck on. The
backend's PUT /auth/me/notifications/subscriptions validator requires the
`streams` field (body('streams').isArray()), but kotlinx.serialization omits a
property equal to its default (encodeDefaults=false). NotificationSubscriptionsDto
defaulted `streams` to emptyList(), so an empty set serialized to `{}` and the
backend rejected it 400 "Validation failed". Any non-empty set included the
field, so only the last toggle-off broke — regardless of which stream it was.

Remove the default from NotificationSubscriptionsDto.streams so kotlinx always
emits the field; an empty set now sends `{"streams":[]}` (200). The one call
site already passes streams explicitly and the server always returns the field,
so response decoding is unaffected. Add a regression test asserting the empty
DTO serializes to `{"streams":[]}` under the production Json config.

Verified on-device (AVD) against the live site and via the live API
(`{}` -> 400, `{"streams":[]}` -> 200).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 11:36:32 -05:00
f0a3b6c03e Merge pull request 'fix(nav): show the player game-data groups to staff' (#24) from fix/staff-player-menu into main
All checks were successful
SonarQube / analysis (push) Successful in 56s
Release APK / release (push) Successful in 9m55s
Reviewed-on: #24
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 08:32:57 +00:00
3aeb295342 fix(nav): show the player game-data groups to staff
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m8s
Staff are a superset of players (all player abilities plus their staff
tools), and the backend's player self-service surface is role-agnostic,
but MenuAccess.PLAYER gated "My characters/vendors/houses" on
role == player — so a signed-in admin/editor/moderator saw neither the
menu items nor, via the greyed personal streams, their own notification
options, even with linked characters.

Gate MenuAccess.PLAYER on isPlayer OR isStaff. The notifications screen
needs no change: once the backend returns the caller's linked accounts
(paired with RunicGateway/website), hasLinkedAccount resolves and the
personal streams enable themselves.

Tests: MenuAccessTest now asserts every staff role sees the player
game-data groups and a PLAYER entry, and an unrecognized role / anon
still cannot. Full unit suite passes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-22 02:18:33 -05:00
03d4ef6fad Merge pull request 'feat(auth): trusted devices & recovery codes on the mobile client' (#23) from feature/trusted-devices-mfa into main
All checks were successful
SonarQube / analysis (push) Successful in 1m14s
Release APK / release (push) Successful in 9m37s
Reviewed-on: #23
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-07-22 06:26:54 +00:00
a1fa4901ef @
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m22s
feat(auth): trusted devices & recovery codes on the mobile client

Consumes the merged backend trusted-device + MFA feature
(RunicGateway/website#93, docs#32) per docs/android/PLAN.md §4.1.1.

Login (POST /auth/mobile/login):
- "Trust this device" checkbox and a "use a recovery code instead"
  toggle on the 401 { totpRequired } step; sends trustDevice /
  recoveryCode / device_name and replays a stored X-Trust-Token.
- A returned trustToken is stored in a dedicated, username-scoped
  EncryptedSharedPreferences file (runic_trust, AES-256-GCM), separate
  from the session store so it deliberately SURVIVES logout — the token
  is only consulted at a fresh login, so clearing it there would make
  the feature a no-op. Cleared only on a Settings→Server switch,
  untrust-all, or server-side revocation. (Supersedes the handoff note
  that said clear-on-logout; matches the canonical rg_trust design.)

Account → Security:
- Trusted Devices screen: list / revoke one / untrust all / trust this
  device (persists the returned token).
- Recovery Codes screen: remaining count + password-stepped regenerate
  with a show-once copy/share display; the one-time batch from enabling
  2FA is also surfaced on the account screen.

Login-time trust cap (trustLimitReached) is surfaced + resolved on the
Trusted Devices screen rather than a blocking login modal, since the
native login has already issued the session.

Tests: DTO decode for all new wire shapes + AccountRepository logic
(the 409 cap-body parse, revoke, recovery). 154 unit tests pass;
assembleDebug clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
@
2026-07-22 00:41:56 -05:00
65 changed files with 3541 additions and 88 deletions

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Render an ASCII tree of tracked files, read from stdin (one path per line).
Used by the `sync-project-tree` workflow to regenerate this repo's PROJECT_TREE.md
snapshot in the RunicGateway/docs repo. Feed it `git ls-files`:
git ls-files | python3 .gitea/scripts/gen_tree.py <root-label>
Deterministic ordering: directories before files, each group sorted
case-insensitively with the raw name as a tiebreak. Output uses the classic
`tree(1)` box-drawing style so the result is stable across runs and platforms.
"""
import sys
def build(paths):
root = {}
for p in paths:
p = p.strip().replace("\\", "/")
if not p:
continue
node = root
for part in p.split("/"):
node = node.setdefault(part, {})
return root
def render(node, prefix, lines):
entries = list(node.items())
# directories (non-empty children dict) before files, then case-insensitive name
entries.sort(key=lambda kv: (0 if kv[1] else 1, kv[0].lower(), kv[0]))
for i, (name, child) in enumerate(entries):
last = i == len(entries) - 1
branch = "└── " if last else "├── "
suffix = "/" if child else ""
lines.append(f"{prefix}{branch}{name}{suffix}")
if child:
render(child, prefix + (" " if last else ""), lines)
def main():
try:
sys.stdout.reconfigure(encoding="utf-8", newline="\n")
except AttributeError:
pass
root_label = sys.argv[1] if len(sys.argv) > 1 else "."
tree = build(sys.stdin.read().splitlines())
lines = [f"{root_label}/"]
render(tree, "", lines)
sys.stdout.write("\n".join(lines) + "\n")
if __name__ == "__main__":
main()

View File

@@ -18,11 +18,12 @@
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
# dashboard when you want to.
#
# Scope: this analyses the Kotlin source directly (the Sonar scanner reads
# sonar-project.properties). It does NOT run a Gradle build, so no Android SDK /
# JDK install is needed — the Kotlin analyzer is source-based. See the "Optional
# enrichment" note in sonar-project.properties for wiring in Android Lint /
# coverage reports later.
# Scope: the Sonar scanner reads sonar-project.properties and analyses the Kotlin
# source directly. Before the scan we run the JVM unit tests + JaCoCo so SonarQube
# receives real coverage (sonar.coverage.jacoco.xmlReportPaths) — otherwise it
# reports 0% and the coverage gate fails despite the test suite existing. That
# Gradle step needs JDK 17 + the Android SDK (same toolchain as pr-checks.yml);
# the runner container is bare, so base tools are apt-installed first.
name: SonarQube
@@ -40,6 +41,16 @@ jobs:
analysis:
runs-on: ubuntu-latest
steps:
# The bare runner container lacks git/curl/unzip (checkout + sdkmanager need
# them) and we install JDK 17 from the Ubuntu archive rather than
# actions/setup-java (this runner can't reach api.adoptium.net). Mirrors
# pr-checks.yml — see its header note.
- name: Install base tools + JDK 17
run: |
apt-get update
apt-get install -y git curl unzip openjdk-17-jdk-headless
echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV"
- name: Check out (full history for accurate new-code + blame)
uses: actions/checkout@v4
with:
@@ -47,6 +58,31 @@ jobs:
# compute "new code". A shallow clone degrades both.
fetch-depth: 0
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Install Android SDK packages
run: |
set +o pipefail
yes | sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
restore-keys: |
gradle-${{ runner.os }}-
# Produce the JaCoCo XML the scan reports as coverage. Scoped to the debug
# variant (matches enableUnitTestCoverage) to keep peak memory down.
- name: Unit tests + JaCoCo coverage
run: |
chmod +x ./gradlew
./gradlew --no-daemon testDebugUnitTest jacocoTestReport
- name: Run SonarQube scan
uses: sonarsource/sonarqube-scan-action@v4
env:

View File

@@ -0,0 +1,111 @@
name: sync-project-tree
# Keeps this repo's file-layout snapshot (docs/android/PROJECT_TREE.md in the
# RunicGateway/docs repo) current. On every push to `main` it regenerates the
# tree from tracked files and, if it changed, opens (or force-updates) a pull
# request against the docs repo. It never writes to the docs repo's `main`
# directly. Auth reuses the same REGISTRY_USER / REGISTRY_TOKEN secrets the
# other workflows use (the token needs repo read/write on RunicGateway/docs).
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: sync-project-tree
cancel-in-progress: true
env:
GITEA_HOST: gitea.whitlocktech.com
DOCS_REPO: RunicGateway/docs
SELF_REPO: RunicGateway/Android-app
DOCS_PATH: android/PROJECT_TREE.md
TREE_TITLE: Android App
ROOT_LABEL: android-app
PR_BRANCH: chore/sync-android-tree
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Check out this repo
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Ensure python3 is available
run: |
set -euo pipefail
command -v python3 >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq python3; }
- name: Render PROJECT_TREE.md from tracked files
run: |
set -euo pipefail
mkdir -p _sync
{
printf '# %s — Project Tree\n\n' "${TREE_TITLE}"
printf '> **Auto-generated.** This file is maintained by the `sync-project-tree` CI workflow in\n'
printf '> the [`%s`](https://%s/%s) repository, which\n' "${SELF_REPO}" "${GITEA_HOST}" "${SELF_REPO}"
printf '> opens a pull request here whenever the tracked file layout on `main` changes. Do not edit\n'
printf '> by hand — changes will be overwritten by the next sync.\n\n'
printf 'A snapshot of the tracked files in the repository (build output, dependencies, and other\n'
printf 'git-ignored paths are excluded).\n\n'
printf '```text\n'
git ls-files | python3 .gitea/scripts/gen_tree.py "${ROOT_LABEL}"
printf '```\n'
} > _sync/PROJECT_TREE.md
echo "----- generated ${DOCS_PATH} -----"
cat _sync/PROJECT_TREE.md
- name: Open or update the docs PR if the tree changed
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
# Secrets can carry a trailing CR/LF depending on how they were pasted;
# strip line breaks before they land in a URL or Authorization header.
CI_USER="$(printf '%s' "${REGISTRY_USER}" | tr -d '\r\n')"
CI_TOKEN="$(printf '%s' "${REGISTRY_TOKEN}" | tr -d '\r\n')"
API="https://${GITEA_HOST}/api/v1/repos/${DOCS_REPO}"
REMOTE="https://${CI_USER}:${CI_TOKEN}@${GITEA_HOST}/${DOCS_REPO}.git"
git clone --depth 1 "${REMOTE}" docs_repo
cd docs_repo
git config user.name "runic-docs-bot"
git config user.email "ci@whitlocktech.com"
mkdir -p "$(dirname "${DOCS_PATH}")"
cp ../_sync/PROJECT_TREE.md "${DOCS_PATH}"
git add "${DOCS_PATH}"
if git diff --cached --quiet; then
echo "PROJECT_TREE.md already up to date — nothing to sync."
exit 0
fi
SHORT_SHA="$(echo "${GITHUB_SHA:-local}" | cut -c1-7)"
git checkout -B "${PR_BRANCH}"
git commit -m "docs(tree): sync ${DOCS_PATH} from ${SELF_REPO}@${SHORT_SHA} [skip ci]"
git push --force "${REMOTE}" "HEAD:${PR_BRANCH}"
# Open a PR only if one isn't already open for this branch (a force-push
# to an existing open PR's head updates it in place).
OPEN="$(curl -sSf -H "Authorization: token ${CI_TOKEN}" \
"${API}/pulls?state=open&limit=50" \
| jq --arg b "${PR_BRANCH}" '[.[] | select(.head.ref == $b)] | length')"
if [ "${OPEN}" = "0" ]; then
curl -sSf -X POST "${API}/pulls" \
-H "Authorization: token ${CI_TOKEN}" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg head "${PR_BRANCH}" \
--arg base "main" \
--arg title "docs(tree): sync ${DOCS_PATH}" \
--arg body "Automated project-tree sync from [\`${SELF_REPO}\`](https://${GITEA_HOST}/${SELF_REPO}), regenerated from tracked files on \`main\`. Merge once the layout looks right; the workflow will keep this branch current until then." \
'{head: $head, base: $base, title: $title, body: $body}')" \
>/dev/null
echo "Opened a new docs PR for ${PR_BRANCH}."
else
echo "Existing open docs PR for ${PR_BRANCH} was updated via force-push."
fi

View File

@@ -10,6 +10,11 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
jacoco
}
jacoco {
toolVersion = "0.8.12"
}
// Release signing material (PLAN.md §12) is never committed. It is read from, in
@@ -78,6 +83,11 @@ android {
}
buildTypes {
debug {
// Produce a JaCoCo .exec from JVM unit tests so SonarQube receives real
// coverage (§12.1). Debug-only: the scan analyses the debug variant.
enableUnitTestCoverage = true
}
release {
// R8 full-mode minify + resource shrink (§7: no offline cache, so a lean
// release APK). Keep rules live in proguard-rules.pro.
@@ -170,3 +180,35 @@ dependencies {
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}
// JaCoCo XML coverage from the JVM unit tests, consumed by SonarQube (§12.1). Generated,
// DI (Hilt), and Compose-scaffold classes are excluded so they don't dilute the number;
// pure-@Composable UI is excluded on the Sonar side (sonar.coverage.exclusions) because
// JVM unit tests can't execute composable bodies without Robolectric.
tasks.register<JacocoReport>("jacocoTestReport") {
dependsOn("testDebugUnitTest")
group = "verification"
description = "Generates JaCoCo XML/HTML coverage for the debug unit tests."
reports {
xml.required.set(true)
html.required.set(true)
}
val coverageExcludes = listOf(
"**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/Manifest*.*",
"**/*_Hilt*.*", "**/Hilt_*.*", "**/*_Factory*.*", "**/*_MembersInjector*.*",
"**/*_Impl*.*", "**/di/**", "**/*Module.*", "**/*Module$*.*",
"**/*ComposableSingletons*.*", "**/ComposableSingletons$*.*",
)
val buildDirFile = layout.buildDirectory.get().asFile
classDirectories.setFrom(
fileTree("$buildDirFile/tmp/kotlin-classes/debug") { exclude(coverageExcludes) },
)
sourceDirectories.setFrom(files("src/main/java", "src/main/kotlin"))
executionData.setFrom(
fileTree(buildDirFile) {
include("outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec")
},
)
}

View File

@@ -0,0 +1,34 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import android.os.Build
import javax.inject.Inject
import javax.inject.Singleton
/**
* Supplies a friendly label for this device, sent as `device_name` at login so a
* trusted-device / active-session row is recognizable in the account lists
* (TRUSTED_DEVICES_MFA.md). Behind an interface so the auth repository stays free of
* `android.os.Build` and unit-testable on the JVM.
*/
fun interface DeviceNameProvider {
/** A human label like "Google Pixel 8", or null if nothing meaningful is available. */
fun deviceName(): String?
}
/** Production impl: manufacturer + model from [Build] (e.g. "Samsung SM-S918B"). */
@Singleton
class BuildDeviceNameProvider @Inject constructor() : DeviceNameProvider {
override fun deviceName(): String? {
val manufacturer = Build.MANUFACTURER?.trim().orEmpty()
val model = Build.MODEL?.trim().orEmpty()
val label = when {
model.isEmpty() -> manufacturer
manufacturer.isEmpty() || model.startsWith(manufacturer, ignoreCase = true) -> model
else -> "$manufacturer $model"
}.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
return label.take(100).ifBlank { null }
}
}

View File

@@ -0,0 +1,65 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* [TrustTokenStore] backed by its **own** EncryptedSharedPreferences file
* (Tink/AES-256-GCM), distinct from the session store so it is never wiped by
* [SessionManager.onSignedOut] — the trust token must outlive a logout to do its
* job (TRUSTED_DEVICES_MFA.md). The token is stored alongside the username it was
* minted for so [tokenFor] only returns it for a matching login.
*
* The prefs handle is lazy so a device that never trusts pays the keystore cost
* only if a token is actually stored or read.
*/
@Singleton
class EncryptedTrustTokenStore @Inject constructor(
@param:ApplicationContext private val context: Context,
) : TrustTokenStore {
private val prefs: SharedPreferences by lazy {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
context,
PREFS_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
}
override fun tokenFor(username: String): String? {
val token = prefs.getString(KEY_TOKEN, null) ?: return null
val owner = prefs.getString(KEY_USERNAME, null) ?: return null
// Case-insensitive: usernames are matched case-insensitively server-side.
return if (owner.equals(username, ignoreCase = true)) token else null
}
override fun save(username: String, token: String) {
prefs.edit()
.putString(KEY_TOKEN, token)
.putString(KEY_USERNAME, username)
.apply()
}
override fun clear() {
prefs.edit().clear().apply()
}
private companion object {
const val PREFS_NAME = "runic_trust"
const val KEY_TOKEN = "trust_token"
const val KEY_USERNAME = "trust_username"
}
}

View File

@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.auth
/**
* At-rest home for the opaque trusted-device token (TRUSTED_DEVICES_MFA.md). It is
* the native analogue of the web `rg_trust` cookie: a device that holds a valid
* token skips the TOTP step on its next login (never the password).
*
* Deliberately **separate** from [TokenStore] and untouched by session teardown —
* the token must **survive logout and a dead-refresh sign-out**, because it is only
* ever consulted at a *fresh* login (exactly the moment after the session is gone).
* Clearing it there would make the feature a no-op. It is scoped to the username it
* was minted for so it is never replayed for a different account on a shared device,
* and is cleared only by an explicit untrust, a Settings → Server switch, or a
* server-side revocation (password change/reset, TOTP disable) that renders it dead.
*
* Tokens are sensitive, so the production impl uses EncryptedSharedPreferences —
* never plain prefs or logs. Kept behind an interface for an in-memory test fake.
*/
interface TrustTokenStore {
/** The stored trust token for [username], or null if this device isn't trusted for them. */
fun tokenFor(username: String): String?
/** Persist [token] as the trust token for [username] (overwrites any prior one). */
fun save(username: String, token: String)
/** Drop the trust token — untrust-all and the Settings → Server hard reset. */
fun clear()
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import kotlinx.coroutines.flow.Flow
/**
* The live shard SSE feed as a cold flow of lifecycle + frame events (PLAN.md §6.2,
* §7). Extracted as an interface so consumers (e.g. [com.runicgateway.app.data.repository.ShardRepository])
* depend on the capability, not the OkHttp-backed [ShardStreamClient] — the boards
* can then be unit-tested against a fake stream instead of a real network connection.
*/
interface ShardStream {
fun events(): Flow<ShardStreamEvent>
}

View File

@@ -40,7 +40,7 @@ class ShardStreamClient @Inject constructor(
baseClient: OkHttpClient,
private val baseUrlHolder: BaseUrlHolder,
private val json: Json,
) {
) : ShardStream {
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
// so the read timeout must be disabled or the idle stream would be killed.
private val sseClient: OkHttpClient = baseClient.newBuilder()
@@ -56,7 +56,7 @@ class ShardStreamClient @Inject constructor(
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
* `{ kind, … }` payload the boards merge in place.
*/
fun events(): Flow<ShardStreamEvent> = channelFlow {
override fun events(): Flow<ShardStreamEvent> = channelFlow {
var backoffMs = INITIAL_BACKOFF_MS
while (isActive) {
val url = baseUrlHolder.current?.resolve(STREAM_PATH)

View File

@@ -10,6 +10,7 @@ import com.runicgateway.app.data.api.dto.MobileTokenResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.POST
@@ -27,9 +28,15 @@ import retrofit2.http.POST
interface AuthApi {
// Literal header value required by Retrofit @Headers; matches Http.NO_SESSION_HEADER.
// [trustToken] rides the `X-Trust-Token` header (TRUSTED_DEVICES_MFA.md): a valid
// token bound to this user lets the server skip the TOTP step. Retrofit omits the
// header entirely when it is null, so an untrusted device sends nothing.
@Headers("X-Runic-No-Session: 1")
@POST("api/v1/auth/mobile/login")
suspend fun login(@Body body: MobileLoginRequest): Response<MobileTokenResponse>
suspend fun login(
@Body body: MobileLoginRequest,
@Header("X-Trust-Token") trustToken: String? = null,
): Response<MobileTokenResponse>
@POST("api/v1/auth/mobile/logout")
suspend fun logout(@Body body: MobileLogoutRequest): Response<Unit>

View File

@@ -7,11 +7,21 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.RevokedCountDto
import com.runicgateway.app.data.api.dto.RevokedFlagDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.HTTP
import retrofit2.http.PATCH
@@ -52,4 +62,28 @@ interface MeApi {
// path template explicit alongside the provider argument.
@HTTP(method = "DELETE", path = "api/v1/auth/me/account/identities/{provider}")
suspend fun unlinkIdentity(@Path("provider") provider: String): Unit
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) — devices allowed to skip TOTP ──
@GET("api/v1/auth/me/trusted-devices")
suspend fun trustedDevices(): List<TrustedDeviceDto>
// Raw [Response] so the caller can read the `409 { error, devices }` cap body,
// which a thrown HttpException would discard.
@POST("api/v1/auth/me/trusted-devices")
suspend fun trustThisDevice(@Body body: TrustDeviceRequest): Response<TrustDeviceResultDto>
@DELETE("api/v1/auth/me/trusted-devices/{id}")
suspend fun revokeTrustedDevice(@Path("id") id: Long): RevokedFlagDto
@DELETE("api/v1/auth/me/trusted-devices")
suspend fun revokeAllTrustedDevices(): RevokedCountDto
// ── Recovery (backup) codes ──────────────────────────────────────────────
@GET("api/v1/auth/me/account/recovery-codes/status")
suspend fun recoveryCodesStatus(): RecoveryStatusDto
@POST("api/v1/auth/me/account/recovery-codes/generate")
suspend fun generateRecoveryCodes(@Body body: RecoveryGenerateRequest): RecoveryCodesDto
}

View File

@@ -55,9 +55,78 @@ data class TotpSetupDto(
@Serializable
data class TotpCodeRequest(val code: String)
/** Result of enabling/disabling 2FA. */
/**
* Result of enabling/disabling 2FA. Enabling also returns the freshly generated
* single-use [recoveryCodes] **once** (null on disable and for older backends) — the
* app shows them for the user to save and never persists them.
*/
@Serializable
data class TotpStateDto(val totp_enabled: Boolean = false)
data class TotpStateDto(
val totp_enabled: Boolean = false,
val recoveryCodes: List<String>? = null,
)
// ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ───────────────
/**
* An active trusted device (`GET /auth/me/trusted-devices`): a browser/app allowed
* to skip the TOTP step at login. Never carries the token. Timestamps are ISO-8601
* strings shown as-is (advisory display).
*/
@Serializable
data class TrustedDeviceDto(
val id: Long = 0,
val platform: String? = null,
val deviceName: String? = null,
val userAgent: String? = null,
val createdAt: String? = null,
val lastUsedAt: String? = null,
val expiresAt: String? = null,
)
/** `POST /auth/me/trusted-devices` body — an optional friendly label. */
@Serializable
data class TrustDeviceRequest(val deviceName: String? = null)
/**
* `POST /auth/me/trusted-devices` success (native): the opaque [trustToken] to store
* and replay via `X-Trust-Token`. Web receives the token as a cookie and no body token.
*/
@Serializable
data class TrustDeviceResultDto(
val trusted: Boolean = false,
val trustToken: String? = null,
)
/**
* `409 { error: "trusted_device_limit", devices }` from a trust attempt at the cap —
* the app lists [devices] and asks the user to revoke one, then retry.
*/
@Serializable
data class TrustedDeviceLimitDto(
val error: String? = null,
val devices: List<TrustedDeviceDto> = emptyList(),
)
/** `DELETE /auth/me/trusted-devices/:id` — idempotent single-revoke result. */
@Serializable
data class RevokedFlagDto(val revoked: Boolean = false)
/** `DELETE /auth/me/trusted-devices` — count of devices untrusted ("untrust all"). */
@Serializable
data class RevokedCountDto(val revoked: Int = 0)
/** `GET /auth/me/account/recovery-codes/status` — remaining unused count only. */
@Serializable
data class RecoveryStatusDto(val remaining: Int = 0)
/** `POST /auth/me/account/recovery-codes/generate` body — password step-up. */
@Serializable
data class RecoveryGenerateRequest(val currentPassword: String? = null)
/** A fresh single-use recovery-code batch, returned **once** (generate + totp enable). */
@Serializable
data class RecoveryCodesDto(val recoveryCodes: List<String> = emptyList())
/** A linked external identity (`GET /auth/me/account/identities`). */
@Serializable

View File

@@ -12,12 +12,23 @@ import kotlinx.serialization.Serializable
* safe (§8, recorded for M1).
*/
/** `POST /auth/mobile/login` body. [code] is only sent on the 2FA retry. */
/**
* `POST /auth/mobile/login` body (trusted-devices contract, TRUSTED_DEVICES_MFA.md).
* [code] is only sent on the 2FA retry; [recoveryCode] is its single-use fallback
* (sent instead of [code]). [trustDevice] asks the server to remember this device so
* future logins skip the second factor — on success the response carries a
* [MobileTokenResponse.trustToken] the app stores and replays via `X-Trust-Token`.
* [device_name] labels the resulting trusted-device / session row (snake_case to
* match the backend field exactly).
*/
@Serializable
data class MobileLoginRequest(
val username: String,
val password: String,
val code: String? = null,
val recoveryCode: String? = null,
val trustDevice: Boolean? = null,
val device_name: String? = null,
)
/** `POST /auth/mobile/refresh` body. */
@@ -34,6 +45,11 @@ data class MobileLogoutRequest(
/**
* Success payload from login and refresh: the token pair, the access lifetime
* (a zeit/ms duration string, e.g. "15m"), and the safe (secret-stripped) user.
*
* Login additionally carries the trusted-device outcome when `trustDevice` was set:
* [trustToken] is the opaque token to persist + replay (present only when the trust
* was accepted), or [trustLimitReached] + [devices] when the per-user cap blocked it
* (the login itself still succeeded). Refresh never sets these.
*/
@Serializable
data class MobileTokenResponse(
@@ -41,6 +57,9 @@ data class MobileTokenResponse(
val refreshToken: String,
val expiresIn: String? = null,
val user: SafeUserDto,
val trustToken: String? = null,
val trustLimitReached: Boolean = false,
val devices: List<TrustedDeviceDto> = emptyList(),
)
/** The minimal, non-sensitive user the app needs to render + gate the menu (§5). */

View File

@@ -60,8 +60,15 @@ data class NotificationStreamsDto(
* `GET/PUT /auth/me/notifications/subscriptions` — the user's opted-in stream ids.
* PUT replaces the full set; unknown ids are dropped server-side and the stored set
* echoed back.
*
* [streams] intentionally has NO default: this DTO doubles as the PUT body, and the
* backend validator requires the `streams` field (`body('streams').isArray()`).
* kotlinx omits a property equal to its default (encodeDefaults=false), so a default
* of `emptyList()` would drop the field when the user clears their LAST subscription,
* sending `{}` → 400 "Validation failed" (the "can't turn off the last one" bug). With
* no default the empty list always serializes as `{"streams":[]}`. Do not re-add a default.
*/
@Serializable
data class NotificationSubscriptionsDto(
val streams: List<String> = emptyList(),
val streams: List<String>,
)

View File

@@ -10,10 +10,19 @@ import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.TrustedDeviceLimitDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.Json
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
@@ -26,6 +35,7 @@ import javax.inject.Singleton
@Singleton
class AccountRepository @Inject constructor(
private val api: MeApi,
private val json: Json,
) {
suspend fun getAccount(): ApiResult<PlayerAccountDto> = safeApiCall { api.getAccount() }
@@ -48,4 +58,63 @@ class AccountRepository @Inject constructor(
suspend fun unlinkIdentity(provider: String): ApiResult<Unit> =
safeApiCall { api.unlinkIdentity(provider) }
// ── Trusted devices (TRUSTED_DEVICES_MFA.md) ───────────────────────────
suspend fun trustedDevices(): ApiResult<List<TrustedDeviceDto>> =
safeApiCall { api.trustedDevices() }
/** The distinct outcomes of trusting the current device — the cap is a first-class case. */
sealed interface TrustOutcome {
/** Trusted; [trustToken] is the opaque token to persist (native). */
data class Trusted(val trustToken: String?) : TrustOutcome
/** At the per-user cap — [devices] must be pruned before retrying. */
data class LimitReached(val devices: List<TrustedDeviceDto>) : TrustOutcome
data object NetworkError : TrustOutcome
data object ServerError : TrustOutcome
}
/**
* Trust the current device. Reads the raw response so the `409 { error, devices }`
* cap body survives (a thrown [retrofit2.HttpException] would discard it).
*/
suspend fun trustThisDevice(deviceName: String? = null): TrustOutcome {
val response = try {
api.trustThisDevice(TrustDeviceRequest(deviceName))
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
return TrustOutcome.NetworkError
} catch (_: Exception) {
return TrustOutcome.ServerError
}
if (response.isSuccessful) {
return TrustOutcome.Trusted(response.body()?.trustToken)
}
if (response.code() == 409) {
val devices = runCatching {
val raw = response.errorBody()?.string()
if (raw.isNullOrBlank()) emptyList()
else json.decodeFromString<TrustedDeviceLimitDto>(raw).devices
}.getOrDefault(emptyList())
return TrustOutcome.LimitReached(devices)
}
return TrustOutcome.ServerError
}
suspend fun revokeTrustedDevice(id: Long): ApiResult<Boolean> =
safeApiCall { api.revokeTrustedDevice(id).revoked }
suspend fun revokeAllTrustedDevices(): ApiResult<Int> =
safeApiCall { api.revokeAllTrustedDevices().revoked }
// ── Recovery (backup) codes ────────────────────────────────────────────
suspend fun recoveryCodesStatus(): ApiResult<RecoveryStatusDto> =
safeApiCall { api.recoveryCodesStatus() }
/** Regenerate the single-use codes (password step-up). Returned once — never stored. */
suspend fun generateRecoveryCodes(currentPassword: String?): ApiResult<RecoveryCodesDto> =
safeApiCall { api.generateRecoveryCodes(RecoveryGenerateRequest(currentPassword)) }
}

View File

@@ -3,7 +3,9 @@
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.data.api.AuthApi
import com.runicgateway.app.data.api.SsoApi
@@ -12,6 +14,7 @@ import com.runicgateway.app.data.api.dto.MobileLogoutRequest
import com.runicgateway.app.data.api.dto.MobileTokenResponse
import com.runicgateway.app.data.api.dto.SsoProviderDto
import com.runicgateway.app.data.api.dto.TotpRequiredError
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.json.Json
@@ -32,6 +35,8 @@ class AuthRepository @Inject constructor(
private val ssoApi: SsoApi,
private val sessionManager: SessionManager,
private val pushManager: PushManager,
private val trustTokenStore: TrustTokenStore,
private val deviceNameProvider: DeviceNameProvider,
private val json: Json,
) {
@@ -73,7 +78,15 @@ class AuthRepository @Inject constructor(
/** Outcome of a login attempt (§4.1). */
sealed interface LoginResult {
data object Success : LoginResult
/**
* Signed in. [trustLimitReached] is true when "trust this device" was asked
* for but the per-user cap blocked it (the login still succeeded, but no trust
* token was issued); [devices] then lists the trusted devices to manage.
*/
data class Success(
val trustLimitReached: Boolean = false,
val devices: List<TrustedDeviceDto> = emptyList(),
) : LoginResult
/** The account has 2FA on — reveal the code field and resubmit with a code. */
data object TotpRequired : LoginResult
@@ -89,9 +102,32 @@ class AuthRepository @Inject constructor(
data object NetworkError : LoginResult
}
suspend fun login(username: String, password: String, code: String? = null): LoginResult {
/**
* Native login (TRUSTED_DEVICES_MFA.md). A stored trust token bound to [username]
* rides the `X-Trust-Token` header so a trusted device skips the TOTP step. A
* second factor is either a [code] (TOTP) or a single-use [recoveryCode]. With
* [trustDevice], the server may return a fresh trust token to persist for next time.
*/
suspend fun login(
username: String,
password: String,
code: String? = null,
recoveryCode: String? = null,
trustDevice: Boolean = false,
): LoginResult {
val storedTrustToken = trustTokenStore.tokenFor(username)
val response: Response<MobileTokenResponse> = try {
authApi.login(MobileLoginRequest(username = username, password = password, code = code))
authApi.login(
MobileLoginRequest(
username = username,
password = password,
code = code,
recoveryCode = recoveryCode,
trustDevice = trustDevice.takeIf { it },
device_name = if (trustDevice) deviceNameProvider.deviceName() else null,
),
trustToken = storedTrustToken,
)
} catch (e: CancellationException) {
throw e
} catch (_: IOException) {
@@ -100,8 +136,14 @@ class AuthRepository @Inject constructor(
if (response.isSuccessful) {
val body = response.body() ?: return LoginResult.ServerError
// Persist a freshly minted trust token (scoped to this account) so the next
// login skips the second factor — it deliberately outlives logout.
body.trustToken?.let { trustTokenStore.save(username, it) }
sessionManager.onSignedIn(body.accessToken, body.refreshToken, body.user)
return LoginResult.Success
return LoginResult.Success(
trustLimitReached = body.trustLimitReached,
devices = body.devices,
)
}
return when (response.code()) {
@@ -111,6 +153,20 @@ class AuthRepository @Inject constructor(
}
}
/**
* Persist a trust token minted by the self-service "trust this device" action
* (Account → Trusted Devices), scoped to [username] exactly like the login path.
*/
fun saveTrustToken(username: String, token: String) = trustTokenStore.save(username, token)
/**
* Drop the locally stored trust token so this device stops skipping the TOTP step
* (used after "untrust all" and on a Settings → Server switch). Server-side
* revocation makes any surviving token inert anyway — the next login just prompts
* for the code — so this is a client-side cleanliness step, never load-bearing.
*/
fun clearTrustToken() = trustTokenStore.clear()
/**
* Revoke this session (or, with [allDevices], every session) and clear local
* tokens (§4.3). Best-effort: the local session is torn down even if the

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.ServerUrl
import com.runicgateway.app.core.prefs.ServerPreferences
@@ -26,6 +27,7 @@ class ConnectionRepository @Inject constructor(
private val prefs: ServerPreferences,
private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig,
) {
@@ -106,6 +108,9 @@ class ConnectionRepository @Inject constructor(
}
pushManager.setNtfyUrl(null)
sessionManager.onSignedOut()
// The trust token is bound to the old host — drop it so we don't replay it
// against a different shard (it survives a plain logout, but not a host switch).
trustTokenStore.clear()
prefs.clear()
baseUrlHolder.set(null)
}

View File

@@ -3,7 +3,7 @@
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.net.ShardStreamClient
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
@@ -35,7 +35,7 @@ import javax.inject.Singleton
@Singleton
class ShardRepository @Inject constructor(
private val api: PublicApi,
private val stream: ShardStreamClient,
private val stream: ShardStream,
private val json: Json,
) {
// ── Snapshots ────────────────────────────────────────────────────────

View File

@@ -9,6 +9,8 @@ import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.net.AuthInterceptor
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.HostSelectionInterceptor
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamClient
import com.runicgateway.app.core.net.TokenAuthenticator
import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi
@@ -94,6 +96,12 @@ object NetworkModule {
@Singleton
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
/** Expose the live SSE feed as the [ShardStream] capability so repositories depend
* on the interface (unit-testable against a fake), not the OkHttp-backed client. */
@Provides
@Singleton
fun provideShardStream(client: ShardStreamClient): ShardStream = client
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)

View File

@@ -3,8 +3,12 @@
*/
package com.runicgateway.app.di
import com.runicgateway.app.core.auth.BuildDeviceNameProvider
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.auth.EncryptedTokenStore
import com.runicgateway.app.core.auth.EncryptedTrustTokenStore
import com.runicgateway.app.core.auth.TokenStore
import com.runicgateway.app.core.auth.TrustTokenStore
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
import com.runicgateway.app.core.auth.sso.PendingSsoStore
import dagger.Binds
@@ -25,4 +29,13 @@ abstract class StorageModule {
@Binds
@Singleton
abstract fun bindPendingSsoStore(impl: EncryptedPendingSsoStore): PendingSsoStore
/** The trusted-device token store — its own encrypted file, outlives session teardown. */
@Binds
@Singleton
abstract fun bindTrustTokenStore(impl: EncryptedTrustTokenStore): TrustTokenStore
@Binds
@Singleton
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
}

View File

@@ -51,6 +51,8 @@ import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.home.HomeScreen
@@ -339,12 +341,27 @@ private fun RunicNavHost(
roleLabel = stringResource(roleLabelRes(s.user.role)),
onSignOut = onSignOut,
onSignOutEverywhere = onSignOutEverywhere,
onOpenTrustedDevices = { navController.navigate(Routes.ACCOUNT_TRUSTED_DEVICES) },
onOpenRecoveryCodes = { navController.navigate(Routes.ACCOUNT_RECOVERY_CODES) },
)
Session.SignedOut -> LaunchedEffect(Unit) {
navController.navigateTopLevel(Routes.HOME)
}
}
}
composable(Routes.ACCOUNT_TRUSTED_DEVICES) {
// Signed-in only; a drop (sign-out/demotion) sends the user home (§4.3).
when (session) {
is Session.SignedIn -> TrustedDevicesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.ACCOUNT_RECOVERY_CODES) {
when (session) {
is Session.SignedIn -> RecoveryCodesScreen()
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
}
}
composable(Routes.NOTIFICATIONS) {
// Signed-in only; a sign-out (or demotion) sends the user home rather than
// leaving stale settings up. The backend gates every call regardless (§5).

View File

@@ -30,7 +30,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment

View File

@@ -84,7 +84,6 @@ fun AdminSupportScreen(
replyTo?.let { page ->
RespondDialog(
page = page,
onDismiss = { replyTo = null },
onSend = { message, close ->
viewModel.respond(page.pageId, message, close)
@@ -122,7 +121,6 @@ private fun SupportPageCard(
@Composable
private fun RespondDialog(
page: SupportPageDto,
onDismiss: () -> Unit,
onSend: (message: String, close: Boolean) -> Unit,
) {

View File

@@ -65,6 +65,8 @@ fun AccountScreen(
roleLabel: String,
onSignOut: () -> Unit,
onSignOutEverywhere: () -> Unit,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
modifier: Modifier = Modifier,
viewModel: AccountViewModel = hiltViewModel(),
) {
@@ -78,10 +80,15 @@ fun AccountScreen(
) {
IdentityCard(username = username, roleLabel = roleLabel)
// One-time recovery codes surfaced right after enabling 2FA — save them now.
state.recoveryCodesOnce?.let { codes ->
RecoveryCodesShowOnceCard(codes, onDismiss = viewModel::dismissRecoveryCodes)
}
when (val account = state.account) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(account.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> AccountSections(account.data, state, viewModel)
is UiState.Success -> AccountSections(account.data, state, viewModel, onOpenTrustedDevices, onOpenRecoveryCodes)
}
HorizontalDivider(Modifier.padding(vertical = 20.dp))
@@ -117,13 +124,34 @@ private fun AccountSections(
account: PlayerAccountDto,
state: AccountViewModel.State,
viewModel: AccountViewModel,
onOpenTrustedDevices: () -> Unit,
onOpenRecoveryCodes: () -> Unit,
) {
UsernameSection(account, state, viewModel)
PasswordSection(account, state, viewModel)
TwoFactorSection(account, state, viewModel)
SecuritySection(onOpenTrustedDevices, onOpenRecoveryCodes)
IdentitiesSection(state, viewModel)
}
/**
* Links to the dedicated trusted-device and recovery-code screens
* (TRUSTED_DEVICES_MFA.md). Kept simple — the management UX lives on those screens.
*/
@Composable
private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCodes: () -> Unit) {
SectionCard(R.string.account_security_title) {
OutlinedButton(
onClick = onOpenTrustedDevices,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
) { Text(stringResource(R.string.account_security_trusted_devices)) }
OutlinedButton(
onClick = onOpenRecoveryCodes,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.account_security_recovery_codes)) }
}
}
@Composable
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {

View File

@@ -49,6 +49,8 @@ class AccountViewModel @Inject constructor(
val busy: Boolean = false,
/** The pending TOTP enrollment (QR shown) between setup and enable. */
val totpSetup: TotpSetupDto? = null,
/** The single-use recovery codes returned once when 2FA was just enabled. */
val recoveryCodesOnce: List<String>? = null,
val feedback: Feedback? = null,
)
@@ -122,9 +124,12 @@ class AccountViewModel @Inject constructor(
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.totpEnable(code.trim())) {
when (val result = accountRepository.totpEnable(code.trim())) {
is ApiResult.Ok -> {
_state.update { it.copy(totpSetup = null) }
// 2FA enable returns the fresh recovery-code batch once — surface it.
_state.update {
it.copy(totpSetup = null, recoveryCodesOnce = result.data.recoveryCodes?.takeIf(List<String>::isNotEmpty))
}
finish(Section.TOTP, true, R.string.account_totp_enabled)
reloadAccount()
}
@@ -134,6 +139,9 @@ class AccountViewModel @Inject constructor(
}
}
/** Dismiss the one-time recovery-code batch shown after enabling 2FA. */
fun dismissRecoveryCodes() = _state.update { it.copy(recoveryCodesOnce = null) }
fun disableTotp(code: String) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }

View File

@@ -5,6 +5,7 @@ package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -16,6 +17,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
@@ -123,6 +125,24 @@ fun LoginScreen(
)
if (state.totpRequired) {
if (state.useRecoveryCode) {
OutlinedTextField(
value = state.recoveryCode,
onValueChange = viewModel::onRecoveryCodeChange,
singleLine = true,
enabled = !state.submitting,
label = { Text(stringResource(R.string.login_recovery_code)) },
supportingText = { Text(stringResource(R.string.login_recovery_hint)) },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Go,
),
keyboardActions = KeyboardActions(onGo = { viewModel.submit() }),
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
} else {
OutlinedTextField(
value = state.code,
onValueChange = viewModel::onCodeChange,
@@ -141,6 +161,39 @@ fun LoginScreen(
)
}
// Toggle between authenticator code and a single-use recovery code.
TextButton(
onClick = { viewModel.onUseRecoveryCodeChange(!state.useRecoveryCode) },
enabled = !state.submitting,
modifier = Modifier.align(Alignment.Start),
) {
Text(
stringResource(
if (state.useRecoveryCode) R.string.login_use_totp_instead
else R.string.login_use_recovery_instead,
),
)
}
// "Trust this device" → skip the 2FA step on future logins here.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
) {
Checkbox(
checked = state.trustDevice,
onCheckedChange = viewModel::onTrustDeviceChange,
enabled = !state.submitting,
)
Text(
text = stringResource(R.string.login_trust_device),
style = MaterialTheme.typography.bodyMedium,
)
}
}
state.error?.let { err ->
Text(
text = stringResource(loginErrorRes(err)),

View File

@@ -39,8 +39,14 @@ class LoginViewModel @Inject constructor(
val username: String = "",
val password: String = "",
val code: String = "",
/** A single-use recovery code, entered instead of [code] when [useRecoveryCode]. */
val recoveryCode: String = "",
/** True once the account is known to have 2FA on — reveal the code field. */
val totpRequired: Boolean = false,
/** "Enter a recovery code instead" — swap the TOTP field for the recovery field. */
val useRecoveryCode: Boolean = false,
/** "Trust this device" — skip the 2FA step on future logins (TRUSTED_DEVICES_MFA.md). */
val trustDevice: Boolean = false,
val submitting: Boolean = false,
val error: LoginError? = null,
val signedIn: Boolean = false,
@@ -83,6 +89,16 @@ class LoginViewModel @Inject constructor(
fun onCodeChange(value: String) =
_state.update { it.copy(code = value.filter(Char::isDigit).take(8), error = null) }
/** Recovery codes are alphanumeric; keep it permissive, just trim length + noise. */
fun onRecoveryCodeChange(value: String) =
_state.update { it.copy(recoveryCode = value.filterNot(Char::isWhitespace).take(32), error = null) }
fun onTrustDeviceChange(value: Boolean) = _state.update { it.copy(trustDevice = value) }
/** Toggle between the TOTP field and the recovery-code field on the 2FA step. */
fun onUseRecoveryCodeChange(value: Boolean) =
_state.update { it.copy(useRecoveryCode = value, error = null) }
val registerUrl: String? get() = websiteUrls.register()
val forgotPasswordUrl: String? get() = websiteUrls.forgotPassword()
@@ -137,30 +153,55 @@ class LoginViewModel @Inject constructor(
fun submit() {
val s = _state.value
if (s.submitting) return
if (s.username.isBlank() || s.password.isBlank()) {
_state.update { it.copy(error = LoginError.INVALID_CREDENTIALS) }
return
}
// If 2FA is being requested, a code must accompany the resubmit.
if (s.totpRequired && s.code.isBlank()) {
_state.update { it.copy(error = LoginError.BAD_CODE) }
val validationError = validateForSubmit(s)
if (validationError != null) {
_state.update { it.copy(error = validationError) }
return
}
_state.update { it.copy(submitting = true, error = null) }
viewModelScope.launch {
val code = s.code.trim().takeIf { it.isNotBlank() }
when (authRepository.login(s.username.trim(), s.password, code)) {
LoginResult.Success ->
// Only one second factor is sent; the recovery toggle picks which.
val code = s.code.trim().takeIf { it.isNotBlank() && !s.useRecoveryCode }
val recoveryCode = s.recoveryCode.trim().takeIf { it.isNotBlank() && s.useRecoveryCode }
val result = authRepository.login(
username = s.username.trim(),
password = s.password,
code = code,
recoveryCode = recoveryCode,
trustDevice = s.trustDevice,
)
applyLoginResult(result)
}
}
/** Pre-flight form checks for [submit]; returns the error to surface, or null if ready to send. */
private fun validateForSubmit(s: UiState): LoginError? {
if (s.username.isBlank() || s.password.isBlank()) return LoginError.INVALID_CREDENTIALS
// If 2FA is being requested, the chosen second factor must accompany the resubmit.
if (s.totpRequired) {
val factor = if (s.useRecoveryCode) s.recoveryCode else s.code
if (factor.isBlank()) return LoginError.BAD_CODE
}
return null
}
/** Folds a [LoginResult] back into the UI state (clears [UiState.submitting] on every path). */
private fun applyLoginResult(result: LoginResult) = when (result) {
is LoginResult.Success ->
// The trusted-device cap (result.trustLimitReached) is an edge case:
// login succeeded but the device wasn't remembered. It's surfaced +
// managed on the Trusted Devices screen rather than blocking sign-in.
_state.update { it.copy(submitting = false, signedIn = true) }
LoginResult.TotpRequired ->
// Reveal the code field; a wrong code re-lands here as BAD_CODE.
// Reveal the 2FA fields; a wrong code/recovery code re-lands here as BAD_CODE.
_state.update {
val hadFactor = if (it.useRecoveryCode) it.recoveryCode.isNotBlank() else it.code.isNotBlank()
it.copy(
submitting = false,
totpRequired = true,
error = if (it.code.isNotBlank()) LoginError.BAD_CODE else null,
error = if (hadFactor) LoginError.BAD_CODE else null,
)
}
@@ -176,6 +217,4 @@ class LoginViewModel @Inject constructor(
LoginResult.NetworkError ->
_state.update { it.copy(submitting = false, error = LoginError.NETWORK) }
}
}
}
}

View File

@@ -0,0 +1,153 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import android.content.Intent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.ui.UiState
/**
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
* password-stepped regenerate that reveals a fresh single-use batch **once**. The
* codes are shown only in memory — copy or share them before leaving; they are never
* stored on the device.
*/
@Composable
fun RecoveryCodesScreen(
modifier: Modifier = Modifier,
viewModel: RecoveryCodesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var currentPassword by rememberSaveable { mutableStateOf("") }
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(stringResource(R.string.recovery_codes_title), style = MaterialTheme.typography.titleLarge)
Text(
stringResource(R.string.recovery_codes_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
val remainingText = when (val r = state.remaining) {
is UiState.Success -> stringResource(R.string.recovery_codes_remaining, r.data)
is UiState.Error -> stringResource(R.string.recovery_codes_remaining_unknown)
UiState.Loading -> stringResource(R.string.recovery_codes_remaining_loading)
}
Text(remainingText, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(top = 16.dp))
state.freshCodes?.let { codes ->
RecoveryCodesShowOnceCard(codes, onDismiss = { viewModel.dismissFreshCodes(); currentPassword = "" })
}
OutlinedTextField(
value = currentPassword,
onValueChange = { currentPassword = it },
singleLine = true,
enabled = !state.busy,
label = { Text(stringResource(R.string.account_password_current)) },
supportingText = { Text(stringResource(R.string.recovery_codes_password_hint)) },
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
)
state.error?.let { err ->
Text(
text = stringResource(err),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
Button(
onClick = { viewModel.regenerate(currentPassword) },
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
) { Text(stringResource(R.string.recovery_codes_regenerate)) }
}
}
/**
* A show-once display of a freshly generated recovery-code batch, with copy/share and
* a dismiss. Shared by this screen and the "2FA just enabled" surface on AccountScreen.
*/
@Composable
fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
val context = LocalContext.current
val clipboard = LocalClipboardManager.current
val joined = remember(codes) { codes.joinToString("\n") }
Card(Modifier.fillMaxWidth().padding(top = 16.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
Text(
stringResource(R.string.recovery_codes_new_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
codes.forEach { code ->
Text(
code,
style = MaterialTheme.typography.bodyLarge.copy(fontFamily = FontFamily.Monospace),
modifier = Modifier.padding(top = 8.dp),
)
}
Row(Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(
onClick = { clipboard.setText(AnnotatedString(joined)) },
) { Text(stringResource(R.string.recovery_codes_copy)) }
OutlinedButton(
onClick = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, joined)
}
context.startActivity(Intent.createChooser(send, null))
},
) { Text(stringResource(R.string.recovery_codes_share)) }
Button(onClick = onDismiss) { Text(stringResource(R.string.recovery_codes_done)) }
}
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.repository.AccountRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
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 javax.inject.Inject
/**
* Drives Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): the remaining-count
* status and a password-stepped regenerate that surfaces a fresh single-use batch
* **once** (never persisted). The freshly generated codes live only in memory until
* the user leaves the screen or dismisses them.
*/
@HiltViewModel
class RecoveryCodesViewModel @Inject constructor(
private val accountRepository: AccountRepository,
) : ViewModel() {
data class State(
/** Remaining unused codes (the status endpoint). */
val remaining: UiState<Int> = UiState.Loading,
/** A just-generated batch to show once, or null. Cleared on dismiss/leave. */
val freshCodes: List<String>? = null,
val busy: Boolean = false,
@param:StringRes val error: Int? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(remaining = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
}
}
/** Regenerate the codes; [currentPassword] is required for accounts that have one. */
fun regenerate(currentPassword: String?) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, error = null, freshCodes = null) }
viewModelScope.launch {
when (val result = accountRepository.generateRecoveryCodes(currentPassword?.takeIf { it.isNotBlank() })) {
is ApiResult.Ok -> {
_state.update { it.copy(busy = false, freshCodes = result.data.recoveryCodes) }
// Refresh the remaining count to reflect the new batch.
_state.update { it.copy(remaining = accountRepository.recoveryCodesStatus().toUiState().map { s -> s.remaining }) }
}
is ApiResult.HttpError ->
_state.update { it.copy(busy = false, error = R.string.recovery_codes_error) }
is ApiResult.NetworkError ->
_state.update { it.copy(busy = false, error = R.string.error_network) }
}
}
}
/** Drop the shown-once batch from memory (user saved them / navigated away). */
fun dismissFreshCodes() = _state.update { it.copy(freshCodes = null) }
}
/** Map an [UiState] success value (local helper mirroring ApiResult.map). */
private inline fun <T, R> UiState<T>.map(transform: (T) -> R): UiState<R> = when (this) {
is UiState.Success -> UiState.Success(transform(data))
is UiState.Loading -> UiState.Loading
is UiState.Error -> this
}

View File

@@ -0,0 +1,136 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
/**
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
* the TOTP step at login. Trust the current device, revoke one, or untrust all. The
* server re-checks ownership on every call; this screen just renders the outcomes.
*/
@Composable
fun TrustedDevicesScreen(
modifier: Modifier = Modifier,
viewModel: TrustedDevicesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
stringResource(R.string.trusted_devices_title),
style = MaterialTheme.typography.titleLarge,
)
Text(
stringResource(R.string.trusted_devices_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp),
)
state.feedback?.let { fb ->
Text(
text = stringResource(fb.messageRes),
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(top = 12.dp),
)
}
when (val devices = state.devices) {
is UiState.Loading -> LoadingView(Modifier.padding(top = 32.dp))
is UiState.Error -> ErrorView(devices.kind, onRetry = viewModel::load, modifier = Modifier.padding(top = 32.dp))
is UiState.Success -> {
if (devices.data.isEmpty()) {
Text(
stringResource(R.string.trusted_devices_empty),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 24.dp),
)
} else {
devices.data.forEach { device ->
TrustedDeviceRow(device, state.busy, onRevoke = { viewModel.revoke(device.id) })
}
}
HorizontalDivider(Modifier.padding(vertical = 20.dp))
Button(
onClick = viewModel::trustThisDevice,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth(),
) { Text(stringResource(R.string.trusted_devices_trust_this)) }
if (devices.data.isNotEmpty()) {
OutlinedButton(
onClick = viewModel::revokeAll,
enabled = !state.busy,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) { Text(stringResource(R.string.trusted_devices_untrust_all)) }
}
}
}
}
}
@Composable
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f)) {
Text(
text = device.deviceName?.takeIf { it.isNotBlank() }
?: device.platform?.replaceFirstChar { it.uppercase() }
?: stringResource(R.string.trusted_devices_unknown),
style = MaterialTheme.typography.bodyLarge,
)
device.lastUsedAt?.let {
Text(
stringResource(R.string.trusted_devices_last_used, it),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
TextButton(onClick = onRevoke, enabled = !busy) {
Text(stringResource(R.string.trusted_devices_revoke))
}
}
}
}

View File

@@ -0,0 +1,125 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.auth
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.DeviceNameProvider
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.repository.AccountRepository
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
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 javax.inject.Inject
/**
* Drives the Trusted Devices screen (TRUSTED_DEVICES_MFA.md): list the devices
* allowed to skip the TOTP step, trust the current one (persisting the returned
* token via [AuthRepository]), revoke one, and untrust all. The trust action folds
* the `409` cap into a first-class [Feedback] telling the user to revoke one first.
*/
@HiltViewModel
class TrustedDevicesViewModel @Inject constructor(
private val accountRepository: AccountRepository,
private val authRepository: AuthRepository,
private val sessionManager: com.runicgateway.app.core.auth.SessionManager,
private val deviceNameProvider: DeviceNameProvider,
) : ViewModel() {
/** A one-shot result banner shown above the list. */
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
data class State(
val devices: UiState<List<TrustedDeviceDto>> = UiState.Loading,
val busy: Boolean = false,
val feedback: Feedback? = null,
)
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
}
fun load() {
_state.update { it.copy(devices = UiState.Loading) }
viewModelScope.launch {
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
}
}
/** Trust the current device; persist the returned token so future logins skip 2FA. */
fun trustThisDevice() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (val outcome = accountRepository.trustThisDevice(deviceNameProvider.deviceName())) {
is TrustOutcome.Trusted -> {
// Bind the fresh token to the signed-in username (mirrors the login path).
val username = sessionManager.state.value.let {
(it as? com.runicgateway.app.core.auth.Session.SignedIn)?.user?.username
}
if (outcome.trustToken != null && username != null) {
authRepository.saveTrustToken(username, outcome.trustToken)
}
finish(true, R.string.trusted_devices_trusted)
reload()
}
is TrustOutcome.LimitReached -> finish(false, R.string.trusted_devices_limit)
TrustOutcome.NetworkError -> finish(false, R.string.error_network)
TrustOutcome.ServerError -> finish(false, R.string.trusted_devices_error)
}
}
}
fun revoke(id: Long) {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.revokeTrustedDevice(id)) {
is ApiResult.Ok -> {
finish(true, R.string.trusted_devices_revoked)
reload()
}
else -> finish(false, R.string.trusted_devices_error)
}
}
}
fun revokeAll() {
if (_state.value.busy) return
_state.update { it.copy(busy = true, feedback = null) }
viewModelScope.launch {
when (accountRepository.revokeAllTrustedDevices()) {
is ApiResult.Ok -> {
// Every device is untrusted now, including this one — drop the local token.
authRepository.clearTrustToken()
finish(true, R.string.trusted_devices_revoked_all)
reload()
}
else -> finish(false, R.string.trusted_devices_error)
}
}
}
fun clearFeedback() = _state.update { it.copy(feedback = null) }
private suspend fun reload() {
_state.update { it.copy(devices = accountRepository.trustedDevices().toUiState()) }
}
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
}

View File

@@ -21,7 +21,12 @@ enum class MenuAccess {
/** Visible to any signed-in account (§5, "My Account"). */
SIGNED_IN,
/** Visible only to a player — the linked game-data groups (§6.3). */
/**
* The linked game-data groups (§6.3). Visible to any player **or** staff:
* staff are a superset of players (all player abilities plus their staff
* tools), and the backend's player self-service surface is role-agnostic, so
* a signed-in admin/editor/moderator sees + uses their own characters too.
*/
PLAYER,
/** Visible to any staff role (admin/editor/moderator) — the M10 staff surface (§1). */
@@ -70,7 +75,7 @@ fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry>
when (entry.access) {
MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && session.user.isPlayer
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
}

View File

@@ -18,6 +18,10 @@ object Routes {
const val LOGIN = "login"
const val ACCOUNT = "account"
/** MFA management, reached from Account (TRUSTED_DEVICES_MFA.md). Signed-in only. */
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
/** Opt-in push notification settings (§11, signed-in). */
const val NOTIFICATIONS = "notifications"

View File

@@ -201,6 +201,49 @@
<string name="account_identity_unlinked">Account unlinked.</string>
<string name="account_identity_error">Couldn\'t unlink that account.</string>
<!-- ── Trusted devices & recovery codes (TRUSTED_DEVICES_MFA.md) ─────── -->
<!-- Login 2FA step -->
<string name="login_recovery_code">Recovery code</string>
<string name="login_recovery_hint">Enter one of your single-use backup codes.</string>
<string name="login_use_recovery_instead">Use a recovery code instead</string>
<string name="login_use_totp_instead">Use your authenticator code instead</string>
<string name="login_trust_device">Trust this device (skip codes for 30 days)</string>
<!-- Account: security section -->
<string name="account_security_title">Security</string>
<string name="account_security_trusted_devices">Trusted devices</string>
<string name="account_security_recovery_codes">Recovery codes</string>
<!-- Trusted devices screen -->
<string name="trusted_devices_title">Trusted devices</string>
<string name="trusted_devices_subtitle">These devices can skip the authentication code at sign-in for 30 days.</string>
<string name="trusted_devices_empty">No trusted devices yet.</string>
<string name="trusted_devices_unknown">Unknown device</string>
<string name="trusted_devices_last_used">Last used %1$s</string>
<string name="trusted_devices_revoke">Revoke</string>
<string name="trusted_devices_trust_this">Trust this device</string>
<string name="trusted_devices_untrust_all">Untrust all devices</string>
<string name="trusted_devices_trusted">This device is now trusted.</string>
<string name="trusted_devices_revoked">Device revoked.</string>
<string name="trusted_devices_revoked_all">All devices untrusted.</string>
<string name="trusted_devices_limit">You\'ve reached the trusted-device limit. Revoke one, then try again.</string>
<string name="trusted_devices_error">Something went wrong. Please try again.</string>
<!-- Recovery codes screen -->
<string name="recovery_codes_title">Recovery codes</string>
<string name="recovery_codes_subtitle">Single-use backup codes let you sign in if you lose your authenticator.</string>
<string name="recovery_codes_remaining">%1$d codes remaining</string>
<string name="recovery_codes_remaining_loading">Checking remaining codes…</string>
<string name="recovery_codes_remaining_unknown">Couldn\'t load the remaining count.</string>
<string name="recovery_codes_password_hint">Enter your current password to generate a new set.</string>
<string name="recovery_codes_regenerate">Generate new codes</string>
<string name="recovery_codes_error">Couldn\'t generate codes. Check your password and that two-factor is on.</string>
<string name="recovery_codes_new_title">Your new recovery codes</string>
<string name="recovery_codes_new_hint">Save these now — they\'re shown only once and each works a single time.</string>
<string name="recovery_codes_copy">Copy</string>
<string name="recovery_codes_share">Share</string>
<string name="recovery_codes_done">Done</string>
<!-- ── Player: game-account linking (§6.3) ─────────────────────────── -->
<string name="player_link_title">Link your game account</string>
<string name="player_link_hint">In game, type [link to get a one-time code, then enter it here to see your characters, vendors and houses.</string>

View File

@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The [ApiResult] helpers: [map] transforms an [ApiResult.Ok] and passes the two
* failure variants through unchanged; [isShardUnavailable] is the 503 "shard down"
* signal the player screens render as offline.
*/
class ApiResultExtrasTest {
@Test fun mapTransformsOkBody() {
val mapped = ApiResult.Ok(listOf(1, 2, 3)).map { it.size }
assertEquals(ApiResult.Ok(3), mapped)
}
@Test fun mapPassesFailuresThroughUnchanged() {
val http: ApiResult<Int> = ApiResult.HttpError(500, "boom")
assertSame(http, http.map { it + 1 })
val cause = RuntimeException("offline")
val network: ApiResult<Int> = ApiResult.NetworkError(cause)
val out = network.map { it + 1 }
assertTrue(out is ApiResult.NetworkError)
assertSame(cause, (out as ApiResult.NetworkError).cause)
}
@Test fun isShardUnavailableOnlyForHttp503() {
assertTrue(ApiResult.HttpError(503).isShardUnavailable())
assertFalse(ApiResult.HttpError(500).isShardUnavailable())
assertFalse(ApiResult.Ok(Unit).isShardUnavailable())
assertFalse(ApiResult.NetworkError(RuntimeException()).isShardUnavailable())
}
}

View File

@@ -54,6 +54,66 @@ class AccountDtoTest {
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
}
@Test fun totpEnableCarriesOneTimeRecoveryCodes() {
// Enabling 2FA now returns the fresh single-use batch once (TRUSTED_DEVICES_MFA.md).
val dto = json.decodeFromString<TotpStateDto>(
"""{"totp_enabled":true,"recoveryCodes":["aaaa-1111","bbbb-2222"]}""",
)
assertTrue(dto.totp_enabled)
assertEquals(listOf("aaaa-1111", "bbbb-2222"), dto.recoveryCodes)
}
@Test fun totpStateDisableHasNoRecoveryCodes() {
// Disable (and older backends) omit the field — must decode to null, not crash.
val dto = json.decodeFromString<TotpStateDto>("""{"totp_enabled":false}""")
assertFalse(dto.totp_enabled)
assertEquals(null, dto.recoveryCodes)
}
@Test fun trustedDeviceDecodes() {
val dto = json.decodeFromString<TrustedDeviceDto>(
"""{"id":5,"platform":"mobile","deviceName":"Pixel 8","userAgent":"RunicGatewayApp/1.0",
"createdAt":"2026-07-20T10:00:00Z","lastUsedAt":"2026-07-22T09:00:00Z",
"expiresAt":"2026-08-19T10:00:00Z"}""",
)
assertEquals(5L, dto.id)
assertEquals("mobile", dto.platform)
assertEquals("Pixel 8", dto.deviceName)
assertEquals("2026-07-22T09:00:00Z", dto.lastUsedAt)
}
@Test fun trustDeviceResultCarriesNativeToken() {
val dto = json.decodeFromString<TrustDeviceResultDto>(
"""{"trusted":true,"trustToken":"opaque-token-abc"}""",
)
assertTrue(dto.trusted)
assertEquals("opaque-token-abc", dto.trustToken)
}
@Test fun trustedDeviceLimitDecodesDevices() {
val dto = json.decodeFromString<TrustedDeviceLimitDto>(
"""{"error":"trusted_device_limit","devices":[
{"id":1,"platform":"web","deviceName":"Firefox"},
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}""",
)
assertEquals("trusted_device_limit", dto.error)
assertEquals(2, dto.devices.size)
assertEquals(2L, dto.devices[1].id)
}
@Test fun recoveryStatusAndCodesDecode() {
assertEquals(7, json.decodeFromString<RecoveryStatusDto>("""{"remaining":7}""").remaining)
val codes = json.decodeFromString<RecoveryCodesDto>(
"""{"recoveryCodes":["c1","c2","c3"]}""",
)
assertEquals(3, codes.recoveryCodes.size)
}
@Test fun revokedResultsDecode() {
assertTrue(json.decodeFromString<RevokedFlagDto>("""{"revoked":true}""").revoked)
assertEquals(4, json.decodeFromString<RevokedCountDto>("""{"revoked":4}""").revoked)
}
@Test fun linkedIdentityDecodes() {
val dto = json.decodeFromString<LinkedIdentityDto>(
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",

View File

@@ -0,0 +1,127 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the staff-operations DTOs (`/admin/…`, PLAN.md §6.4).
* Covers the snake_case `@SerialName` mappings, the `AdminPostDto.isPublished`
* tinyint bridge, nested dashboard shapes, and the request bodies the app encodes.
*/
class AdminDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun dashboardDecodesNestedCountsAndActivity() {
val dto = json.decodeFromString<AdminDashboardDto>(
"""{
"site_mode":"maintenance",
"last_change":{"at":"2026-07-20T10:00:00Z","by":"admin"},
"counts":{"posts":{"news":4,"newsletter":1},"users":37},
"recent_activity":[
{"id":9,"username":"mod","action":"post.publish",
"detail":{"postId":12},"created_at":"2026-07-22T09:00:00Z"}
]
}""",
)
assertEquals("maintenance", dto.siteMode)
assertEquals("admin", dto.lastChange.by)
assertEquals(4, dto.counts.posts["news"])
assertEquals(37, dto.counts.users)
assertEquals(1, dto.recentActivity.size)
assertEquals("post.publish", dto.recentActivity[0].action)
// `detail` is provider-shaped JSON kept as a raw element.
assertEquals(12, dto.recentActivity[0].detail!!.jsonObject["postId"]!!.jsonPrimitive.int)
}
@Test fun dashboardDefaultsWhenKeysAbsent() {
val dto = json.decodeFromString<AdminDashboardDto>("{}")
assertEquals("live", dto.siteMode)
assertTrue(dto.counts.posts.isEmpty())
assertTrue(dto.recentActivity.isEmpty())
}
@Test fun adminPostBridgesPublishedTinyintToBoolean() {
val published = json.decodeFromString<AdminPostDto>(
"""{"id":1,"category":"news","title":"Hi","published":1,"published_at":"2026-07-21T00:00:00Z"}""",
)
assertTrue(published.isPublished)
assertEquals("2026-07-21T00:00:00Z", published.publishedAt)
val draft = json.decodeFromString<AdminPostDto>("""{"id":2,"title":"Draft","published":0}""")
assertFalse(draft.isPublished)
}
@Test fun adminWikiCategoryAndTagDecodeCounts() {
val cat = json.decodeFromString<AdminWikiCategoryDto>(
"""{"id":3,"slug":"lore","title":"Lore","sort_order":2,"page_count":5,"published_count":4}""",
)
assertEquals(2, cat.sortOrder)
assertEquals(5, cat.pageCount)
assertEquals(4, cat.publishedCount)
val tag = json.decodeFromString<AdminWikiTagDto>("""{"id":8,"slug":"pvp","label":"PvP","published_count":11}""")
assertEquals("PvP", tag.label)
assertEquals(11, tag.publishedCount)
}
@Test fun supportPageDecodesSenderActor() {
val dto = json.decodeFromString<SupportPageDto>(
"""{"pageId":"0x1A2B","type":"other","message":"stuck",
"handled":false,"sender":{"name":"Gwen","account":"gwen01"}}""",
)
assertEquals("0x1A2B", dto.pageId)
assertEquals("Gwen", dto.sender?.name)
assertEquals("gwen01", dto.sender?.account)
assertEquals(false, dto.handled)
}
@Test fun supportPageToleratesMissingSender() {
val dto = json.decodeFromString<SupportPageDto>("""{"pageId":"0x01"}""")
assertNull(dto.sender)
assertNull(dto.type)
}
@Test fun siteModeStateDecodesAudit() {
val dto = json.decodeFromString<SiteModeStateDto>(
"""{"site_mode":"maintenance","changed_at":"2026-07-22T08:00:00Z","changed_by":"admin"}""",
)
assertEquals("maintenance", dto.siteMode)
assertEquals("admin", dto.changedBy)
}
@Test fun requestBodiesEncodeWithSnakeCaseKeys() {
assertTrue(json.encodeToString(SiteModeRequest("maintenance")).contains("\"mode\":\"maintenance\""))
assertTrue(json.encodeToString(PublishRequest(true)).contains("\"published\":true"))
assertTrue(json.encodeToString(UnbanRequest("gwen01")).contains("\"account\":\"gwen01\""))
assertTrue(json.encodeToString(BroadcastRequest("hello", hue = 33)).contains("\"hue\":33"))
assertTrue(json.encodeToString(PageRespondRequest("done", close = true)).contains("\"close\":true"))
assertTrue(json.encodeToString(WikiCategoryRequest(slug = "lore", title = "Lore", sortOrder = 1))
.contains("\"sort_order\":1"))
val post = json.encodeToString(PostCreateRequest(category = "news", title = "T", imageUrl = "/img.png"))
assertTrue(post.contains("\"image_url\":\"/img.png\""))
assertTrue(post.contains("\"category\":\"news\""))
val ban = json.encodeToString(BanRequest(account = "x", durationSec = 3600, reason = "afk"))
assertTrue(ban.contains("\"durationSec\":3600"))
val kick = json.encodeToString(KickRequest(serial = "0xFF"))
assertTrue(kick.contains("\"serial\":\"0xFF\""))
}
}

View File

@@ -68,4 +68,35 @@ class AuthDtoTest {
assertNull(dto.expiresIn)
assertEquals("admin", dto.user.role)
}
@Test fun loginCarriesTrustTokenWhenDeviceTrusted() {
// trustDevice accepted → an opaque token to persist + replay (TRUSTED_DEVICES_MFA.md).
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
"trustToken":"opaque-abc"}""",
)
assertEquals("opaque-abc", dto.trustToken)
assertFalse(dto.trustLimitReached)
}
@Test fun loginSignalsTrustLimitWithDevices() {
// At the cap: login still succeeds, but no token; the device list is returned.
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":3,"username":"c","role":"player"},
"trustLimitReached":true,"devices":[{"id":1,"platform":"mobile","deviceName":"Old"}]}""",
)
assertNull(dto.trustToken)
assertTrue(dto.trustLimitReached)
assertEquals(1, dto.devices.size)
}
@Test fun loginWithoutTrustFieldsDefaultsCleanly() {
// A normal (no-trust) login omits every trust field — must not crash or mis-flag.
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":4,"username":"d","role":"player"}}""",
)
assertNull(dto.trustToken)
assertFalse(dto.trustLimitReached)
assertTrue(dto.devices.isEmpty())
}
}

View File

@@ -0,0 +1,68 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Encode/decode tests for the mobile bearer-auth request bodies (`/auth/mobile/…`)
* and the token pair — the snake_case `device_name`, the omit-nulls behaviour, and
* the trusted-device outcome fields on the login response.
*/
class AuthRequestDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun loginRequestEncodesSnakeCaseDeviceNameAndOmitsNulls() {
val body = json.encodeToString(
MobileLoginRequest(username = "gwen", password = "pw", trustDevice = true, device_name = "Pixel 8"),
)
assertTrue(body.contains("\"username\":\"gwen\""))
assertTrue(body.contains("\"device_name\":\"Pixel 8\""))
assertTrue(body.contains("\"trustDevice\":true"))
assertFalse(body.contains("\"code\"")) // null omitted (explicitNulls = false)
}
@Test fun loginRequestCarriesSecondFactorOnRetry() {
val withCode = json.encodeToString(MobileLoginRequest("u", "p", code = "123456"))
assertTrue(withCode.contains("\"code\":\"123456\""))
val withRecovery = json.encodeToString(MobileLoginRequest("u", "p", recoveryCode = "aaaa-1111"))
assertTrue(withRecovery.contains("\"recoveryCode\":\"aaaa-1111\""))
}
@Test fun refreshAndLogoutBodiesEncode() {
assertTrue(json.encodeToString(MobileRefreshRequest("rt")).contains("\"refreshToken\":\"rt\""))
assertTrue(json.encodeToString(MobileLogoutRequest(all = true)).contains("\"all\":true"))
}
@Test fun tokenResponseDecodesTrustOutcome() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","expiresIn":"15m",
"user":{"id":1,"username":"gwen","role":"player"},
"trustToken":"opaque"}""",
)
assertEquals("a", dto.accessToken)
assertEquals("opaque", dto.trustToken)
assertFalse(dto.trustLimitReached)
assertEquals("gwen", dto.user.username)
}
@Test fun tokenResponseDecodesTrustLimitReached() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":1,"username":"g","role":"player"},
"trustLimitReached":true,"devices":[{"id":1,"platform":"web","deviceName":"FF"}]}""",
)
assertTrue(dto.trustLimitReached)
assertEquals(1, dto.devices.size)
}
}

View File

@@ -0,0 +1,82 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the news post + CMS page + contact DTOs (`/public/posts…`,
* `/public/pages/:slug`, `/public/contact`). One [PostDto] shape serves both the
* list (no body) and detail (with body); a [PageDto] keeps block props as raw JSON.
*/
class ContentDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun postDetailDecodesBodyAndImage() {
val dto = json.decodeFromString<PostDto>(
"""{"id":10,"category":"news","title":"Update","slug":"update",
"excerpt":"e","body":"<p>full</p>","image_url":"/i.png",
"published_at":"2026-07-21T00:00:00Z","created_at":"2026-07-20T00:00:00Z"}""",
)
assertEquals(10L, dto.id)
assertEquals("<p>full</p>", dto.body)
assertEquals("/i.png", dto.imageUrl)
assertEquals("2026-07-21T00:00:00Z", dto.publishedAt)
}
@Test fun postListRowToleratesMissingBody() {
val dto = json.decodeFromString<PostDto>("""{"id":11,"category":"newsletter","title":"N"}""")
assertNull(dto.body)
assertNull(dto.imageUrl)
}
@Test fun pageDecodesBlocksWithRawProps() {
val dto = json.decodeFromString<PageDto>(
"""{
"id":3,"slug":"about","title":"About","status":"published",
"blocks":[
{"type":"heading","props":{"text":"Welcome","level":1},"visible":true},
{"type":"divider","props":{}}
],
"publishedAt":"2026-07-01T00:00:00Z"
}""",
)
assertEquals("about", dto.slug)
assertEquals(2, dto.blocks.size)
assertEquals("heading", dto.blocks[0].type)
// props stay a raw JSON object the renderer reads by key.
assertEquals("Welcome", dto.blocks[0].props["text"]!!.jsonPrimitive.content)
assertTrue(dto.blocks[1].visible) // default true when absent
}
@Test fun contactResponseSentAndFallbackVariants() {
val sent = json.decodeFromString<ContactResponse>("""{"sent":true}""")
assertTrue(sent.sent)
assertNull(sent.fallback)
val fallback = json.decodeFromString<ContactResponse>(
"""{"sent":false,"fallback":"mailto","email":"a@b.c"}""",
)
assertEquals("mailto", fallback.fallback)
assertEquals("a@b.c", fallback.email)
}
@Test fun contactRequestEncodesAllFields() {
val body = json.encodeToString(ContactRequest(name = "Gwen", email = "g@x.c", message = "hi"))
assertTrue(body.contains("\"name\":\"Gwen\""))
assertTrue(body.contains("\"email\":\"g@x.c\""))
assertTrue(body.contains("\"message\":\"hi\""))
}
}

View File

@@ -3,6 +3,7 @@
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -58,6 +59,15 @@ class NotificationsDtoTest {
assertEquals(listOf("news.post", "champ.start"), dto.streams)
}
@Test fun emptySubscriptionsStillSerializeStreamsField() {
// Regression: clearing the LAST subscription sends an empty set. The backend
// validator requires `streams`, so it must be present as `[]`, not omitted.
// Uses the production Json config (no encodeDefaults) to prove the field is
// always emitted because the DTO field has no default.
val body = json.encodeToString(NotificationSubscriptionsDto(emptyList()))
assertEquals("""{"streams":[]}""", body)
}
@Test fun settingsPushBlockDecodes() {
val dto = json.decodeFromString<SettingsDto>(
"""{"site_title":"Shard","brand":{"name":"Shard"},"push":{"ntfyUrl":"https://ntfy.shard.tld"}}""",

View File

@@ -0,0 +1,115 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the player self-service game-data DTOs
* (`/player/shard/…`): account linking, roster, the full character sheet, vendors,
* sales, and own-houses. Only the fields the text-only v1 renders are asserted.
*/
class PlayerGameDataDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun linkRequestAndResultRoundTrip() {
assertTrue(json.encodeToString(ShardLinkRequest("ABC123")).contains("\"code\":\"ABC123\""))
val result = json.decodeFromString<ShardLinkResultDto>("""{"linked":true,"account":"acct1"}""")
assertTrue(result.linked)
assertEquals("acct1", result.account)
assertTrue(json.encodeToString(CreateGameAccountRequest("acct1", "pw")).contains("\"account\":\"acct1\""))
}
@Test fun linkedAccountDecodes() {
val dto = json.decodeFromString<ShardLinkDto>(
"""{"account":"acct1","userId":42,"charName":"Gwen","linkedAt":"2026-07-20T00:00:00Z"}""",
)
assertEquals("acct1", dto.account)
assertEquals(42L, dto.userId)
}
@Test fun rosterDecodesCharacters() {
val dto = json.decodeFromString<RosterDto>(
"""{"acct":"acct1","chars":[
{"slot":0,"serial":"0x24C","name":"Gwen","body":401,"online":true},
{"slot":1,"serial":"0x24D","name":"Alt","online":false}]}""",
)
assertEquals(2, dto.chars.size)
assertTrue(dto.chars[0].online)
assertEquals("0x24C", dto.chars[0].serial)
}
@Test fun charSheetDecodesStatsSkillsEquipmentTitlesGuild() {
val dto = json.decodeFromString<CharProfileDto>(
"""{
"serial":"0x24C","name":"Gwen","title":"the Brave","online":true,"acct":"acct1",
"stats":{"str":100,"dex":90,"int":80,"hits":95,"hitsMax":100,
"resist":{"phys":70,"fire":50,"cold":45,"pois":40,"energy":35}},
"skills":[{"n":"Swords","base":100.0,"value":110.0,"cap":120.0}],
"equipment":[{"serial":"0x9","layer":"OneHanded","itemId":5044,"hue":0}],
"titles":{"selected":0,"reward":["1049643"],"fameKarma":"Glorious"},
"guild":{"name":"Knights","abbr":"KoT"},
"governorOf":["Britain"]
}""",
)
assertEquals("Gwen", dto.name)
assertEquals(100, dto.stats!!.str)
assertEquals(70, dto.stats!!.resist!!.phys)
assertEquals(110.0, dto.skills.first().value!!, 0.0)
assertEquals("OneHanded", dto.equipment.first().layer)
assertEquals("Glorious", dto.titles!!.fameKarma)
assertEquals("Knights", dto.guild!!.name)
assertEquals(listOf("Britain"), dto.governorOf)
}
@Test fun charSheetToleratesMinimalPayload() {
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x1","name":"Bare"}""")
assertEquals(null, dto.stats)
assertTrue(dto.skills.isEmpty())
assertTrue(dto.equipment.isEmpty())
assertFalse(dto.online)
}
@Test fun vendorSnapshotAndListingsDecode() {
val dto = json.decodeFromString<VendorSnapshotDto>(
"""{"acct":"acct1","vendors":[
{"serial":"0x9","shopName":"Wares","holdGold":5000,"map":"Felucca","x":1,"y":2,
"listings":[{"serial":"0xA","itemId":3862,"amount":5,"price":250,"forSale":true}]}]}""",
)
val vendor = dto.vendors.first()
assertEquals("Wares", vendor.shopName)
assertEquals(5000L, vendor.holdGold)
val listing = vendor.listings.first()
assertEquals(250L, listing.price)
assertTrue(listing.forSale)
}
@Test fun vendorSaleDecodes() {
val dto = json.decodeFromString<VendorSaleDto>(
"""{"t":1700000000000,"itemType":"katana","amount":1,"price":1000,"commission":50,"ownerAcct":"acct1"}""",
)
assertEquals(1000L, dto.price)
assertEquals(50, dto.commission)
}
@Test fun playerHouseDecodesDecayFields() {
val dto = json.decodeFromString<PlayerHouseDto>(
"""{"serial":"0x40001","stage":"LikeNew","region":"Britain","name":"Keep",
"isIdoc":false,"builtOn":"2026-01-01T00:00:00Z","lastRefreshed":"2026-07-22T00:00:00Z"}""",
)
assertEquals("LikeNew", dto.stage)
assertEquals("Keep", dto.name)
assertFalse(dto.isIdoc)
}
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the public site/identity DTOs (`/public/status`,
* `/public/settings`). Covers the `StatusDto.isMaintenance` derivation, nested
* branding/registration/push blocks, and the additive-field tolerance.
*/
class PublicDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun statusDecodesVersionAndMaintenanceFlag() {
val dto = json.decodeFromString<StatusDto>(
"""{"mode":"MAINTENANCE","status_message":"back soon",
"version":{"service":"web","api":"v1","server":"1.2.3"}}""",
)
assertTrue(dto.isMaintenance) // case-insensitive
assertEquals("back soon", dto.statusMessage)
assertEquals("1.2.3", dto.version.server)
}
@Test fun liveStatusIsNotMaintenance() {
assertFalse(json.decodeFromString<StatusDto>("""{"mode":"live"}""").isMaintenance)
}
@Test fun statusDefaultsWhenEmpty() {
val dto = json.decodeFromString<StatusDto>("{}")
assertEquals("live", dto.mode)
assertFalse(dto.isMaintenance)
assertEquals("", dto.version.api)
}
@Test fun settingsDecodesBrandRegistrationAndPush() {
val dto = json.decodeFromString<SettingsDto>(
"""{
"site_title":"UOMysticmoon","status_message":"welcome",
"registration":{"password":true,"sso":false},
"gameAccountSignup":true,
"brand":{"name":"UOMysticmoon","shortName":"UOM","accent":"#7f99bd",
"logo":"/logo.png","hero":"/hero.png","contactEmail":"a@b.c","url":"https://x"},
"push":{"ntfyUrl":"https://ntfy.example.com"}
}""",
)
assertEquals("UOMysticmoon", dto.siteTitle)
assertTrue(dto.registration.password)
assertFalse(dto.registration.sso)
assertTrue(dto.gameAccountSignup)
assertEquals("#7f99bd", dto.brand.accent)
assertEquals("/logo.png", dto.brand.logo)
assertEquals("https://ntfy.example.com", dto.push.ntfyUrl)
}
@Test fun settingsDefaultsOnOlderBackend() {
// A backend that predates push/branding: nested blocks fall back to defaults.
val dto = json.decodeFromString<SettingsDto>("""{"site_title":"Bare"}""")
assertFalse(dto.registration.password)
assertEquals("", dto.brand.name)
assertEquals(null, dto.push.ntfyUrl)
}
}

View File

@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the public shard board DTOs (`/public/shard/…`), covering the
* computed helpers ([ActorDto.label], [ShardStatusDto.isOnline]) and the
* permissive board payloads (champ/guild/governor/house/presence).
*/
class ShardBoardDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun actorLabelPrefersNameThenAcctThenFallback() {
assertEquals("Gwen", json.decodeFromString<ActorDto>("""{"name":"Gwen","acct":"g01"}""").label)
assertEquals("g01", json.decodeFromString<ActorDto>("""{"acct":"g01"}""").label)
assertEquals("Someone", json.decodeFromString<ActorDto>("{}").label)
}
@Test fun shardStatusIsOnlineOnlyWhenEnabledAndPluginConnected() {
val online = json.decodeFromString<ShardStatusDto>(
"""{"enabled":true,"pluginConnected":true,"onlineCount":42,
"economy":{"accounts":10,"gold":123456.0,"t":1000}}""",
)
assertTrue(online.isOnline)
assertEquals(42, online.onlineCount)
assertEquals(123456.0, online.economy!!.gold!!, 0.0)
assertFalse(json.decodeFromString<ShardStatusDto>("""{"enabled":true,"pluginConnected":false}""").isOnline)
assertFalse(json.decodeFromString<ShardStatusDto>("{}").isOnline)
}
@Test fun champDecodesBossAndProgressFields() {
val dto = json.decodeFromString<ChampDto>(
"""{"serial":"0x1","category":"champion","name":"Rikktor","active":true,
"bossUp":true,"boss":"Rikktor","level":3,"maxLevel":16,"kills":10,"maxKills":100,
"hits":5000,"hitsMax":9000,"map":"Felucca","x":1,"y":2,"z":0}""",
)
assertTrue(dto.active)
assertTrue(dto.bossUp)
assertEquals(16, dto.maxLevel)
assertEquals(5000L, dto.hits)
}
@Test fun guildDecodesLeaderActor() {
val dto = json.decodeFromString<GuildDto>(
"""{"id":7,"name":"Knights","abbr":"KoT","members":12,"online":3,
"leader":{"name":"Arthur","webId":"9931"}}""",
)
assertEquals("Knights", dto.name)
assertEquals("Arthur", dto.leader!!.label)
assertEquals("9931", dto.leader!!.webId)
}
@Test fun governorAndTermDecode() {
val gov = json.decodeFromString<GovernorDto>(
"""{"city":"Britain","governor":{"name":"Dawn"},"electionPhase":"campaign"}""",
)
assertEquals("Britain", gov.city)
assertEquals("Dawn", gov.governor!!.label)
val term = json.decodeFromString<GovernorTermDto>(
"""{"city":"Britain","governor":{"name":"Dawn"},"startedAt":1000,"endedAt":2000,"votes":50}""",
)
assertEquals(50, term.votes)
assertEquals(2000L, term.endedAt)
}
@Test fun houseAndPresenceAndStaffDecode() {
val house = json.decodeFromString<HouseDto>(
"""{"serial":"0x40","name":"Tower","region":"Britain","isIdoc":true,"x":5,"y":6}""",
)
assertTrue(house.isIdoc)
assertEquals("Tower", house.name)
val presence = json.decodeFromString<PresenceDto>(
"""{"count":30,"byFacet":{"Felucca":10,"Trammel":20},"byRegion":{"Britain":5}}""",
)
assertEquals(30, presence.count)
assertEquals(10, presence.byFacet["Felucca"])
val staff = json.decodeFromString<OnlineStaffDto>("""{"serial":"0x2","name":"GM Bob","map":"Felucca","x":1,"y":2,"z":0}""")
assertEquals("GM Bob", staff.name)
}
@Test fun feedEventDecodesPayloadObject() {
val ev = json.decodeFromString<FeedEventDto>(
"""{"id":9,"kind":"champ.spawn","t":1234,"payload":{"name":"Rikktor"},"createdAt":"2026-07-22T00:00:00Z"}""",
)
assertEquals("champ.spawn", ev.kind)
assertTrue(ev.payload!!.containsKey("name"))
}
}

View File

@@ -0,0 +1,46 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the Mobile SSO bridge DTOs (PLAN.md §4.2). Provider
* discovery is public (never secrets); the exchange body uses snake_case
* `code_verifier` to match the backend.
*/
class SsoDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun providerDecodesWithOptionalFields() {
val dto = json.decodeFromString<SsoProviderDto>(
"""{"id":"discord","name":"Discord","icon":"discord","loginUrl":"/auth/discord","priority":2}""",
)
assertEquals("discord", dto.id)
assertEquals("Discord", dto.name)
assertEquals(2, dto.priority)
}
@Test fun providerToleratesMissingOptionals() {
val dto = json.decodeFromString<SsoProviderDto>("""{"id":"oidc","name":"Corp SSO"}""")
assertNull(dto.icon)
assertNull(dto.priority)
}
@Test fun exchangeRequestEncodesSnakeCaseVerifier() {
val body = json.encodeToString(MobileSsoExchangeRequest(code = "abc123", codeVerifier = "v-e-r-i-f-i-e-r"))
assertTrue(body.contains("\"code\":\"abc123\""))
assertTrue(body.contains("\"code_verifier\":\"v-e-r-i-f-i-e-r\""))
}
}

View File

@@ -0,0 +1,66 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the wiki DTOs (`/public/wiki*`). Summary rows omit the body;
* the detail page carries tags, backlinks, and unresolved ("red") link targets.
*/
class WikiDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun summaryRowDecodesWithCategory() {
val dto = json.decodeFromString<WikiSummaryDto>(
"""{"id":5,"slug":"pvp","title":"PvP","excerpt":"combat",
"category_slug":"systems","category_title":"Systems","updated_at":"2026-07-20T00:00:00Z"}""",
)
assertEquals(5L, dto.id)
assertEquals("systems", dto.categorySlug)
assertEquals("Systems", dto.categoryTitle)
assertEquals("combat", dto.excerpt)
}
@Test fun pageDecodesTagsBacklinksAndMissingLinks() {
val dto = json.decodeFromString<WikiPageDto>(
"""{
"id":9,"slug":"housing","title":"Housing","body":"<p>text</p>",
"category_slug":"systems","category_title":"Systems",
"tags":[{"slug":"idoc","label":"IDOC"}],
"backlinks":[{"slug":"pvp","title":"PvP"}],
"missing_links":["nonexistent-page"]
}""",
)
assertEquals("<p>text</p>", dto.body)
assertEquals(1, dto.tags.size)
assertEquals("IDOC", dto.tags[0].label)
assertEquals("pvp", dto.backlinks[0].slug)
assertEquals(listOf("nonexistent-page"), dto.missingLinks)
}
@Test fun pageDefaultsCollectionsWhenAbsent() {
val dto = json.decodeFromString<WikiPageDto>("""{"id":1,"slug":"x","title":"X"}""")
assertTrue(dto.tags.isEmpty())
assertTrue(dto.backlinks.isEmpty())
assertTrue(dto.missingLinks.isEmpty())
}
@Test fun categoryAndTagDecodePublishedCounts() {
val cat = json.decodeFromString<WikiCategoryDto>(
"""{"id":2,"slug":"systems","title":"Systems","description":"d","published_count":12}""",
)
assertEquals(12L, cat.publishedCount)
val tag = json.decodeFromString<WikiTagDto>("""{"id":4,"slug":"idoc","label":"IDOC","published_count":3}""")
assertEquals(3L, tag.publishedCount)
}
}

View File

@@ -0,0 +1,88 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.data.api.dto.BanRequest
import com.runicgateway.app.data.api.dto.BroadcastRequest
import com.runicgateway.app.data.api.dto.KickRequest
import com.runicgateway.app.data.api.dto.PageRespondRequest
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.PublishRequest
import com.runicgateway.app.data.api.dto.SiteModeRequest
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.dto.UnbanRequest
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.util.okUnit
import retrofit2.Response
/**
* A configurable fake of [AdminApi] for the staff-ops repository/ViewModel tests.
* Read endpoints return their `var`; write endpoints returning `Response<Unit>`
* return [unitResponse] (default 200) so a test can drive the 200 / 403 / 503 copy
* branches. Set [error] to throw from every call (network / decode failure paths).
*/
class FakeAdminApi : AdminApi {
var error: Throwable? = null
var dashboard: AdminDashboardDto = AdminDashboardDto()
var siteMode: SiteModeStateDto = SiteModeStateDto()
var posts: List<AdminPostDto> = emptyList()
var createdPost: AdminPostDto = AdminPostDto(id = 0)
var publishedPost: AdminPostDto = AdminPostDto(id = 0)
var wikiCategories: List<AdminWikiCategoryDto> = emptyList()
var createdCategory: AdminWikiCategoryDto = AdminWikiCategoryDto(id = 0)
var wikiTags: List<AdminWikiTagDto> = emptyList()
var supportPages: List<SupportPageDto> = emptyList()
/** Response returned by the bodyless write endpoints (kick/ban/delete/respond/…). */
var unitResponse: Response<Unit> = okUnit()
/** Bodies seen by write calls, so a test can assert what was sent. */
var lastPostCreate: PostCreateRequest? = null
var lastBan: BanRequest? = null
var lastRespond: Pair<String, PageRespondRequest>? = null
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun dashboard(): AdminDashboardDto = reply(dashboard)
override suspend fun setSiteMode(body: SiteModeRequest): SiteModeStateDto = reply(siteMode)
override suspend fun posts(): List<AdminPostDto> = reply(posts)
override suspend fun createPost(body: PostCreateRequest): AdminPostDto {
lastPostCreate = body
return reply(createdPost)
}
override suspend fun publishPost(id: Long, body: PublishRequest): AdminPostDto = reply(publishedPost)
override suspend fun deletePost(id: Long): Response<Unit> = reply(unitResponse)
override suspend fun wikiCategories(): List<AdminWikiCategoryDto> = reply(wikiCategories)
override suspend fun createWikiCategory(body: WikiCategoryRequest): AdminWikiCategoryDto = reply(createdCategory)
override suspend fun deleteWikiCategory(id: Long): Response<Unit> = reply(unitResponse)
override suspend fun wikiTags(): List<AdminWikiTagDto> = reply(wikiTags)
override suspend fun kick(body: KickRequest): Response<Unit> = reply(unitResponse)
override suspend fun ban(body: BanRequest): Response<Unit> {
lastBan = body
return reply(unitResponse)
}
override suspend fun unban(body: UnbanRequest): Response<Unit> = reply(unitResponse)
override suspend fun broadcast(body: BroadcastRequest): Response<Unit> = reply(unitResponse)
override suspend fun supportPages(): List<SupportPageDto> = reply(supportPages)
override suspend fun respondPage(id: String, body: PageRespondRequest): Response<Unit> {
lastRespond = id to body
return reply(unitResponse)
}
override suspend fun closePage(id: String): Response<Unit> = reply(unitResponse)
}

View File

@@ -0,0 +1,47 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
import com.runicgateway.app.data.api.dto.PlayerHouseDto
import com.runicgateway.app.data.api.dto.RosterDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.dto.ShardLinkRequest
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
import com.runicgateway.app.data.api.dto.VendorSaleDto
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
/**
* A configurable fake of [PlayerShardApi] for the player self-service repository /
* ViewModel tests. Set the relevant `var`; set [error] to throw from every call
* (drives the `503 shard offline` / `403 not-linked` / network paths).
*/
class FakePlayerShardApi : PlayerShardApi {
var error: Throwable? = null
var linkResult: ShardLinkResultDto = ShardLinkResultDto()
var accounts: List<ShardLinkDto> = emptyList()
var roster: RosterDto = RosterDto()
var char: CharProfileDto = CharProfileDto()
var vendors: VendorSnapshotDto = VendorSnapshotDto()
var sales: List<VendorSaleDto> = emptyList()
var houses: List<PlayerHouseDto> = emptyList()
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun link(body: ShardLinkRequest): ShardLinkResultDto = reply(linkResult)
override suspend fun createAccount(body: CreateGameAccountRequest): ShardLinkResultDto = reply(linkResult)
override suspend fun accounts(): List<ShardLinkDto> = reply(accounts)
override suspend fun roster(account: String): RosterDto = reply(roster)
override suspend fun char(serial: String): CharProfileDto = reply(char)
override suspend fun vendors(account: String): VendorSnapshotDto = reply(vendors)
override suspend fun sales(): List<VendorSaleDto> = reply(sales)
override suspend fun houses(): List<PlayerHouseDto> = reply(houses)
}

View File

@@ -0,0 +1,98 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.ContactRequest
import com.runicgateway.app.data.api.dto.ContactResponse
import com.runicgateway.app.data.api.dto.EconomySampleDto
import com.runicgateway.app.data.api.dto.FeedEventDto
import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.api.dto.WikiTagDto
/**
* A configurable fake of [PublicApi] for repository/ViewModel tests. Set the
* relevant `var` to the body a call should return; set [error] to make every call
* throw (drives the `ApiResult.HttpError` / `NetworkError` paths). Defaults are
* empty/neutral so a call an assertion doesn't care about never crashes.
*/
class FakePublicApi : PublicApi {
/** When non-null, every call throws this (use `httpError(code)` or an IOException). */
var error: Throwable? = null
var status: StatusDto = StatusDto()
var settings: SettingsDto = SettingsDto()
var posts: List<PostDto> = emptyList()
var post: PostDto = PostDto(id = 0)
var page: PageDto = PageDto(id = 0)
var wikiPages: List<WikiSummaryDto> = emptyList()
var wikiCategories: List<WikiCategoryDto> = emptyList()
var wikiTags: List<WikiTagDto> = emptyList()
var wikiPage: WikiPageDto = WikiPageDto(id = 0)
var contactResponse: ContactResponse = ContactResponse(sent = true)
var shardStatus: ShardStatusDto = ShardStatusDto()
var shardFeed: List<FeedEventDto> = emptyList()
var shardEconomy: List<EconomySampleDto> = emptyList()
var shardOnline: List<OnlineStaffDto> = emptyList()
var shardPresence: PresenceDto = PresenceDto()
var champs: List<ChampDto> = emptyList()
var guilds: List<GuildDto> = emptyList()
var governors: List<GovernorDto> = emptyList()
var governorHistory: List<GovernorTermDto> = emptyList()
var houses: List<HouseDto> = emptyList()
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
var lastContact: ContactRequest? = null
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
override suspend fun getStatus(): StatusDto = reply(status)
override suspend fun getSettings(): SettingsDto = reply(settings)
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
override suspend fun getPage(slug: String): PageDto = reply(page)
override suspend fun getWikiPages(query: String?, category: String?, tag: String?): List<WikiSummaryDto> =
reply(wikiPages)
override suspend fun getWikiCategories(): List<WikiCategoryDto> = reply(wikiCategories)
override suspend fun getWikiTags(): List<WikiTagDto> = reply(wikiTags)
override suspend fun getWikiPage(slug: String): WikiPageDto = reply(wikiPage)
override suspend fun postContact(body: ContactRequest): ContactResponse {
lastContact = body
return reply(contactResponse)
}
override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus)
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)
override suspend fun getShardOnline(): List<OnlineStaffDto> = reply(shardOnline)
override suspend fun getShardPresence(): PresenceDto = reply(shardPresence)
override suspend fun getShardChamps(): List<ChampDto> = reply(champs)
override suspend fun getShardGuilds(): List<GuildDto> = reply(guilds)
override suspend fun getShardGovernors(): List<GovernorDto> = reply(governors)
override suspend fun getShardGovernorHistory(city: String, limit: Int?): List<GovernorTermDto> =
reply(governorHistory)
override suspend fun getShardHouses(): List<HouseDto> = reply(houses)
}

View File

@@ -0,0 +1,19 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* A finite fake of the live [ShardStream] for board-ViewModel tests: it emits the
* given [events] once and completes, so the ViewModel's `collectLive()` finishes
* immediately (no perpetual reconnect loop) and any live-frame handling it triggers
* is exercised deterministically.
*/
class FakeShardStream(private val events: List<ShardStreamEvent> = emptyList()) : ShardStream {
override fun events(): Flow<ShardStreamEvent> = flowOf(*events.toTypedArray())
}

View File

@@ -0,0 +1,120 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.MeApi
import com.runicgateway.app.data.api.dto.ChangePasswordRequest
import com.runicgateway.app.data.api.dto.ChangeUsernameRequest
import com.runicgateway.app.data.api.dto.LinkedIdentityDto
import com.runicgateway.app.data.api.dto.PlayerAccountDto
import com.runicgateway.app.data.api.dto.RecoveryCodesDto
import com.runicgateway.app.data.api.dto.RecoveryGenerateRequest
import com.runicgateway.app.data.api.dto.RecoveryStatusDto
import com.runicgateway.app.data.api.dto.RevokedCountDto
import com.runicgateway.app.data.api.dto.RevokedFlagDto
import com.runicgateway.app.data.api.dto.TotpCodeRequest
import com.runicgateway.app.data.api.dto.TotpSetupDto
import com.runicgateway.app.data.api.dto.TotpStateDto
import com.runicgateway.app.data.api.dto.TrustDeviceRequest
import com.runicgateway.app.data.api.dto.TrustDeviceResultDto
import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.data.api.dto.UsernameResponse
import com.runicgateway.app.data.repository.AccountRepository.TrustOutcome
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.Response
/**
* [AccountRepository] trusted-device + recovery logic (TRUSTED_DEVICES_MFA.md) over a
* fake [MeApi]. The interesting case is the `409` cap: the device list must survive
* into a typed [TrustOutcome.LimitReached] rather than being lost as a bare error.
*/
class AccountTrustedDevicesTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
/** A fake MeApi; only the trusted-device/recovery methods under test are wired. */
private open class FakeMeApi(
var trustResponse: Response<TrustDeviceResultDto>? = null,
var devices: List<TrustedDeviceDto> = emptyList(),
var revokeFlag: Boolean = true,
var revokeCount: Int = 0,
var remaining: Int = 0,
var generated: List<String> = emptyList(),
) : MeApi {
override suspend fun trustedDevices(): List<TrustedDeviceDto> = devices
override suspend fun trustThisDevice(body: TrustDeviceRequest): Response<TrustDeviceResultDto> =
trustResponse ?: Response.success(TrustDeviceResultDto(trusted = true, trustToken = "t"))
override suspend fun revokeTrustedDevice(id: Long): RevokedFlagDto = RevokedFlagDto(revokeFlag)
override suspend fun revokeAllTrustedDevices(): RevokedCountDto = RevokedCountDto(revokeCount)
override suspend fun recoveryCodesStatus(): RecoveryStatusDto = RecoveryStatusDto(remaining)
override suspend fun generateRecoveryCodes(body: RecoveryGenerateRequest): RecoveryCodesDto =
RecoveryCodesDto(generated)
// Unused by these tests.
override suspend fun getAccount(): PlayerAccountDto = PlayerAccountDto()
override suspend fun changeUsername(body: ChangeUsernameRequest): UsernameResponse = UsernameResponse()
override suspend fun changePassword(body: ChangePasswordRequest) = Unit
override suspend fun totpSetup(): TotpSetupDto = TotpSetupDto()
override suspend fun totpEnable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun totpDisable(body: TotpCodeRequest): TotpStateDto = TotpStateDto()
override suspend fun identities(): List<LinkedIdentityDto> = emptyList()
override suspend fun unlinkIdentity(provider: String) = Unit
}
private fun repo(api: MeApi) = AccountRepository(api, json)
@Test fun trustThisDeviceReturnsToken() = runTest {
val api = FakeMeApi(trustResponse = Response.success(TrustDeviceResultDto(true, "opaque-xyz")))
val outcome = repo(api).trustThisDevice("Pixel")
assertTrue(outcome is TrustOutcome.Trusted)
assertEquals("opaque-xyz", (outcome as TrustOutcome.Trusted).trustToken)
}
@Test fun trustThisDeviceParsesCapDevicesFrom409() = runTest {
val body = """{"error":"trusted_device_limit","devices":[
{"id":1,"platform":"web","deviceName":"Firefox"},
{"id":2,"platform":"mobile","deviceName":"Pixel"}]}"""
.toResponseBody("application/json".toMediaTypeOrNull())
val api = FakeMeApi(trustResponse = Response.error(409, body))
val outcome = repo(api).trustThisDevice(null)
assertTrue(outcome is TrustOutcome.LimitReached)
val devices = (outcome as TrustOutcome.LimitReached).devices
assertEquals(2, devices.size)
assertEquals("Pixel", devices[1].deviceName)
}
@Test fun trustThisDeviceOtherErrorIsServerError() = runTest {
val body = """{"message":"boom"}""".toResponseBody("application/json".toMediaTypeOrNull())
val api = FakeMeApi(trustResponse = Response.error(500, body))
assertTrue(repo(api).trustThisDevice(null) is TrustOutcome.ServerError)
}
@Test fun revokeMapsFlagAndCount() = runTest {
val revoked = repo(FakeMeApi(revokeFlag = true)).revokeTrustedDevice(9)
assertTrue(revoked is ApiResult.Ok && revoked.data)
val all = repo(FakeMeApi(revokeCount = 3)).revokeAllTrustedDevices()
assertTrue(all is ApiResult.Ok && all.data == 3)
}
@Test fun recoveryStatusAndGenerateMap() = runTest {
val status = repo(FakeMeApi(remaining = 6)).recoveryCodesStatus()
assertTrue(status is ApiResult.Ok && status.data.remaining == 6)
val gen = repo(FakeMeApi(generated = listOf("a", "b"))).generateRecoveryCodes("pw")
assertTrue(gen is ApiResult.Ok)
assertEquals(listOf("a", "b"), (gen as ApiResult.Ok).data.recoveryCodes)
}
}

View File

@@ -0,0 +1,120 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.WikiRepository
import com.runicgateway.app.ui.home.HomeViewModel
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.news.NewsViewModel
import com.runicgateway.app.ui.news.PostViewModel
import com.runicgateway.app.ui.page.PageViewModel
import com.runicgateway.app.ui.wiki.WikiPageViewModel
import com.runicgateway.app.ui.wiki.WikiViewModel
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/** ViewModels over the public content APIs (news, CMS pages, wiki, home status). */
class ContentViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePublicApi()
private val content = ContentRepository(api)
private val wiki = WikiRepository(api)
private val settings = SettingsRepository(api)
// ── News hub ──────────────────────────────────────────────────────────
@Test fun newsLoadsSelectedCategory() {
api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi"))
val vm = NewsViewModel(content)
assertTrue(vm.state.value is UiState.Success)
assertEquals(1, (vm.state.value as UiState.Success).data.size)
}
@Test fun newsSelectCategoryReloads() {
val vm = NewsViewModel(content)
api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N"))
vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER)
assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value)
assertEquals(1, (vm.state.value as UiState.Success).data.size)
}
@Test fun newsServerErrorIsUiError() {
api.error = httpError(500)
assertTrue(NewsViewModel(content).state.value is UiState.Error)
}
// ── Post detail (SavedStateHandle args) ─────────────────────────────────
@Test fun postDetailLoadsForKnownCategory() {
api.post = PostDto(id = 7, category = "news", title = "Update", body = "<p>x</p>")
val handle = SavedStateHandle(
mapOf(Routes.Args.CATEGORY to "news", Routes.Args.ID_OR_SLUG to "update"),
)
val vm = PostViewModel(content, handle)
assertEquals("Update", (vm.state.value as UiState.Success).data.title)
}
@Test fun postDetailUnknownCategoryIsNotFoundWithoutApiCall() {
val handle = SavedStateHandle(
mapOf(Routes.Args.CATEGORY to "bogus", Routes.Args.ID_OR_SLUG to "x"),
)
val state = PostViewModel(content, handle).state.value
assertTrue(state is UiState.Error)
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
}
// ── CMS page ────────────────────────────────────────────────────────────
@Test fun pageLoadsBySlug() {
api.page = PageDto(id = 3, slug = "about", title = "About")
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "about")))
assertEquals("About", (vm.state.value as UiState.Success).data.title)
}
@Test fun pageNotFoundIsUiError() {
api.error = httpError(404)
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "missing")))
assertEquals(ErrorKind.NOT_FOUND, (vm.state.value as UiState.Error).kind)
}
// ── Wiki index + detail ─────────────────────────────────────────────────
@Test fun wikiIndexLoadsAndTracksQuery() {
api.wikiPages = listOf(WikiSummaryDto(id = 1, slug = "pvp", title = "PvP"))
val vm = WikiViewModel(wiki)
assertTrue(vm.state.value is UiState.Success)
vm.onQueryChange("housing")
assertEquals("housing", vm.query.value)
}
@Test fun wikiPageLoadsBySlug() {
api.wikiPage = WikiPageDto(id = 9, slug = "housing", title = "Housing", body = "b")
val vm = WikiPageViewModel(wiki, SavedStateHandle(mapOf(Routes.Args.SLUG to "housing")))
assertEquals("Housing", (vm.state.value as UiState.Success).data.title)
}
// ── Home status ─────────────────────────────────────────────────────────
@Test fun homeLoadsStatus() {
api.status = StatusDto(mode = "maintenance")
val vm = HomeViewModel(settings)
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
}
@Test fun homeNetworkErrorIsUiError() {
api.error = java.io.IOException("offline")
val state = HomeViewModel(settings).state.value
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
}
}

View File

@@ -0,0 +1,78 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.errorUnit
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminContentViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminContentViewModel(AdminRepository(api))
@Test fun loadsPostsAndWikiOnInit() {
api.posts = listOf(AdminPostDto(id = 1, title = "A", published = 1))
api.wikiCategories = listOf(AdminWikiCategoryDto(id = 2, slug = "lore", title = "Lore"))
api.wikiTags = listOf(AdminWikiTagDto(id = 3, slug = "pvp", label = "PvP"))
val vm = viewModel()
assertTrue(vm.state.value.posts is UiState.Success)
assertEquals(1, (vm.state.value.posts as UiState.Success).data.size)
assertEquals(1, vm.state.value.tags.size)
}
@Test fun createPostRejectsBlankTitleWithoutCallingApi() {
val vm = viewModel()
vm.createPost(category = "news", title = " ", excerpt = "", body = "", published = false)
assertFalse(vm.state.value.feedback!!.ok)
assertEquals(R.string.admin_content_title_required, vm.state.value.feedback!!.messageRes)
assertEquals(null, api.lastPostCreate) // never reached the API
}
@Test fun createPostTrimsAndNullsBlanksThenReloads() {
val vm = viewModel()
vm.createPost(category = "news", title = " Hello ", excerpt = "", body = "b", published = true)
val sent = api.lastPostCreate!!
assertEquals("Hello", sent.title)
assertEquals(null, sent.excerpt) // blank -> null
assertEquals("b", sent.body)
assertTrue(vm.state.value.feedback!!.ok)
assertFalse(vm.state.value.busy)
}
@Test fun togglePublishForbiddenSurfacesForbiddenCopy() {
api.unitResponse = errorUnit(403)
api.error = httpError(403)
val vm = viewModel()
vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1))
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
}
@Test fun createCategoryRejectsBlankFields() {
val vm = viewModel()
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)
assertEquals(R.string.admin_content_cat_fields_required, vm.state.value.feedback!!.messageRes)
}
@Test fun deletePostNetworkErrorShowsNetworkCopy() {
api.error = java.io.IOException("offline")
val vm = viewModel()
vm.deletePost(9)
assertEquals(R.string.error_network, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,70 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.AdminCountsDto
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminDashboardViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminDashboardViewModel(AdminRepository(api))
@Test fun loadsDashboardOnInit() {
api.dashboard = AdminDashboardDto(siteMode = "live", counts = AdminCountsDto(users = 12))
val vm = viewModel()
val state = vm.state.value.dashboard
assertTrue(state is UiState.Success)
assertEquals(12, (state as UiState.Success).data.counts.users)
}
@Test fun loadSurfacesServerErrorAsUiError() {
api.error = httpError(500)
val vm = viewModel()
assertTrue(vm.state.value.dashboard is UiState.Error)
}
@Test fun setSiteModeSuccessUpdatesModeAndClearsSwitching() {
api.dashboard = AdminDashboardDto(siteMode = "live")
api.siteMode = SiteModeStateDto(siteMode = "maintenance")
val vm = viewModel()
vm.setSiteMode("maintenance")
val s = vm.state.value
assertFalse(s.switching)
assertTrue(s.feedback!!.ok)
assertEquals(R.string.admin_site_mode_updated, s.feedback!!.messageRes)
}
@Test fun setSiteModeForbiddenShowsForbiddenCopy() {
api.dashboard = AdminDashboardDto()
api.error = httpError(403)
val vm = viewModel()
vm.setSiteMode("maintenance")
val fb = vm.state.value.feedback!!
assertFalse(fb.ok)
assertEquals(R.string.admin_forbidden, fb.messageRes)
}
@Test fun clearFeedbackResetsBanner() {
api.error = httpError(500)
val vm = viewModel()
vm.setSiteMode("live")
vm.clearFeedback()
assertEquals(null, vm.state.value.feedback)
}
}

View File

@@ -0,0 +1,63 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.errorUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminModerationViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminModerationViewModel(AdminRepository(api))
@Test fun kickWithNoTargetShowsTargetRequired() {
val vm = viewModel()
vm.kick(account = "", serial = "")
assertEquals(R.string.admin_mod_target_required, vm.state.value.feedback!!.messageRes)
}
@Test fun banForwardsFieldsAndSucceeds() {
val vm = viewModel()
vm.ban(account = "gwen", serial = "", durationSec = 3600, reason = "afk")
assertEquals("gwen", api.lastBan!!.account)
assertNull(api.lastBan!!.serial) // blank -> null
assertEquals(3600L, api.lastBan!!.durationSec)
assertEquals("afk", api.lastBan!!.reason)
val fb = vm.state.value.feedback!!
assertTrue(fb.ok)
assertEquals(R.string.admin_mod_banned, fb.messageRes)
assertFalse(vm.state.value.busy)
}
@Test fun shardOfflineMapsTo503Copy() {
api.unitResponse = errorUnit(503)
val vm = viewModel()
vm.kick(account = "x", serial = "")
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
}
@Test fun broadcastRejectsBlankText() {
val vm = viewModel()
vm.broadcast(text = " ", hue = null)
assertEquals(R.string.admin_mod_text_required, vm.state.value.feedback!!.messageRes)
}
@Test fun unbanForbiddenMapsTo403Copy() {
api.unitResponse = errorUnit(403)
val vm = viewModel()
vm.unban("gwen")
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,61 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.errorUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminSupportViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminSupportViewModel(AdminRepository(api))
@Test fun loadsOpenPagesOnInit() {
api.supportPages = listOf(SupportPageDto(pageId = "0x1", message = "help"))
val vm = viewModel()
val pages = vm.state.value.pages
assertTrue(pages is UiState.Success)
assertEquals("0x1", (pages as UiState.Success).data.first().pageId)
}
@Test fun respondRejectsBlankMessage() {
val vm = viewModel()
vm.respond(id = "0x1", message = " ", close = true)
assertEquals(R.string.admin_support_message_required, vm.state.value.feedback!!.messageRes)
assertEquals(null, api.lastRespond)
}
@Test fun respondTrimsMessageAndReloadsOnSuccess() {
api.supportPages = listOf(SupportPageDto(pageId = "0x1"))
val vm = viewModel()
vm.respond(id = "0x1", message = " on it ", close = false)
assertEquals("on it", api.lastRespond!!.second.message)
assertTrue(vm.state.value.feedback!!.ok)
}
@Test fun respondUnknownPageMapsTo404Copy() {
api.unitResponse = errorUnit(404)
val vm = viewModel()
vm.respond(id = "0xZ", message = "hi", close = false)
assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes)
}
@Test fun closeShardOfflineMapsTo503Copy() {
api.unitResponse = errorUnit(503)
val vm = viewModel()
vm.close("0x1")
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.contact
import com.runicgateway.app.data.api.dto.ContactResponse
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContactRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class ContactViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePublicApi()
private fun viewModel() = ContactViewModel(ContactRepository(api))
@Test fun blankFieldsProduceValidationError() {
val vm = viewModel()
vm.onNameChange("Gwen")
vm.send() // email + message still blank
assertEquals(ContactViewModel.Result.ValidationError, vm.state.value.result)
assertEquals(null, api.lastContact) // never hit the API
}
@Test fun successfulSendClearsFieldsAndReportsSent() {
api.contactResponse = ContactResponse(sent = true)
val vm = viewModel()
vm.onNameChange(" Gwen ")
vm.onEmailChange(" g@x.c ")
vm.onMessageChange(" hello ")
vm.send()
assertEquals(ContactViewModel.Result.Sent, vm.state.value.result)
assertEquals("", vm.state.value.name) // fields cleared on success
// Repository trims before sending.
assertEquals("Gwen", api.lastContact!!.name)
assertEquals("g@x.c", api.lastContact!!.email)
}
@Test fun mailerFallbackSurfacesEmail() {
api.contactResponse = ContactResponse(sent = false, fallback = "mailto", email = "team@shard.gg")
val vm = viewModel()
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
vm.send()
val result = vm.state.value.result
assertTrue(result is ContactViewModel.Result.Fallback)
assertEquals("team@shard.gg", (result as ContactViewModel.Result.Fallback).email)
// Fields kept (not a clean send) so the user can retry.
assertEquals("A", vm.state.value.name)
}
@Test fun serverErrorSurfacesFailedWithKind() {
api.error = httpError(500)
val vm = viewModel()
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
vm.send()
val result = vm.state.value.result
assertTrue(result is ContactViewModel.Result.Failed)
assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind)
}
}

View File

@@ -37,12 +37,16 @@ class MenuAccessTest {
assertTrue(visible.contains(Routes.HOME))
}
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
val visible = routes(signedIn(Role.EDITOR))
assertTrue(visible.contains(Routes.ACCOUNT))
// No PLAYER-access entry (the M4 game-data groups) leaks to staff.
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
assertTrue(playerOnly.none { visible.contains(it) })
@Test fun staffSeeThePlayerGameDataGroups() {
// Staff are a superset of players: every staff role sees the PLAYER-access
// game-data groups too (their own linked characters, via the role-agnostic
// /player self-service surface), on top of their staff entries.
val playerGroups = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
for (role in listOf(Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
val visible = routes(signedIn(role))
assertTrue("$role should see Account", visible.contains(Routes.ACCOUNT))
assertTrue("$role should see the player game-data groups", playerGroups.all { visible.contains(it) })
}
}
@Test fun publicEntryCountIsStableAcrossSessions() {
@@ -58,10 +62,13 @@ class MenuAccessTest {
}
@Test fun playerAccessGatedFunction() {
// A synthetic PLAYER-gated entry is visible to a player, hidden from staff/anon.
// A PLAYER-gated entry is visible to a player AND to every staff role
// (staff superset), hidden only from an unrecognized role and anon.
val entries = listOf(MenuEntry("game", 0, MenuAccess.PLAYER))
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER)).isNotEmpty())
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN)).isEmpty())
for (role in listOf(Role.PLAYER, Role.ADMIN, Role.EDITOR, Role.MODERATOR)) {
assertTrue("$role should see a PLAYER entry", visibleEntries(entries, signedIn(role)).isNotEmpty())
}
assertTrue(visibleEntries(entries, signedIn(Role.UNKNOWN)).isEmpty())
assertTrue(visibleEntries(entries, Session.SignedOut).isEmpty())
}

View File

@@ -0,0 +1,89 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.player
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RosterCharDto
import com.runicgateway.app.data.api.dto.RosterDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.PlayerShardRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class CharactersViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val playerApi = FakePlayerShardApi()
private val publicApi = FakePublicApi()
private fun viewModel() = CharactersViewModel(
PlayerShardRepository(playerApi),
SettingsRepository(publicApi),
)
@Test fun loadsAccountsRostersAndSignupFlag() {
playerApi.accounts = listOf(ShardLinkDto(account = "acct1"))
playerApi.roster = RosterDto(acct = "acct1", chars = listOf(RosterCharDto(serial = "0x24C", name = "Gwen")))
publicApi.settings = SettingsDto(gameAccountSignup = true)
val vm = viewModel()
assertEquals(listOf("acct1"), (vm.state.value.accounts as UiState.Success).data)
val roster = vm.state.value.rosters["acct1"]
assertTrue(roster is UiState.Success)
assertEquals("Gwen", (roster as UiState.Success).data.first().name)
assertTrue(vm.state.value.signupEnabled)
}
@Test fun accountsErrorSurfacesError() {
playerApi.error = httpError(503)
assertTrue(viewModel().state.value.accounts is UiState.Error)
}
@Test fun linkBlankCodeIsIgnored() {
val vm = viewModel()
vm.link(" ")
assertEquals(null, vm.state.value.feedback)
}
@Test fun linkSuccessShowsOkAndReloads() {
playerApi.linkResult = ShardLinkResultDto(linked = true, account = "acct1")
val vm = viewModel()
vm.link("CODE1")
val fb = vm.state.value.feedback!!
assertTrue(fb.ok)
assertEquals(R.string.player_link_ok, fb.messageRes)
assertFalse(vm.state.value.busy)
}
@Test fun linkBadCodeMapsTo400Copy() {
playerApi.error = httpError(400)
val vm = viewModel()
vm.link("BAD")
assertEquals(R.string.player_link_bad_code, vm.state.value.feedback!!.messageRes)
}
@Test fun createAccountRejectsShortPasswordWithoutApiCall() {
val vm = viewModel()
vm.createAccount(account = "acct1", password = "short") // < 8 chars
assertEquals(null, vm.state.value.feedback) // guarded before any call/feedback
}
@Test fun createAccountTakenMapsTo409Copy() {
playerApi.error = httpError(409)
val vm = viewModel()
vm.createAccount(account = "acct1", password = "longenough")
assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,83 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.player
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.PlayerHouseDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.dto.VendorDto
import com.runicgateway.app.data.api.dto.VendorSaleDto
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
import com.runicgateway.app.data.repository.PlayerShardRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/** ViewModels over the player self-service game-data API (own chars, vendors, houses). */
class PlayerViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePlayerShardApi()
private val repository = PlayerShardRepository(api)
// ── My houses ───────────────────────────────────────────────────────────
@Test fun myHousesLoadsOwnHouses() {
api.houses = listOf(PlayerHouseDto(serial = "0x40001", name = "Keep", isIdoc = false))
val vm = MyHousesViewModel(repository)
assertEquals("Keep", (vm.state.value as UiState.Success).data.first().name)
}
@Test fun myHousesShardOfflineIsShardOfflineError() {
api.error = httpError(503)
val state = MyHousesViewModel(repository).state.value
assertEquals(ErrorKind.SHARD_OFFLINE, (state as UiState.Error).kind)
}
// ── Character sheet (SavedStateHandle serial) ─────────────────────────────
@Test fun characterLoadsBySerial() {
api.char = CharProfileDto(serial = "0x24C", name = "Gwen", online = true)
val vm = CharacterViewModel(repository, SavedStateHandle(mapOf(Routes.Args.SERIAL to "0x24C")))
assertEquals("Gwen", (vm.state.value as UiState.Success).data.name)
}
@Test fun characterForbiddenIsNotFound() {
api.error = httpError(403)
val vm = CharacterViewModel(repository, SavedStateHandle(mapOf(Routes.Args.SERIAL to "0x1")))
// 403 isn't a mapped status -> SERVER bucket (only 404/429/503 are special-cased).
assertTrue(vm.state.value is UiState.Error)
}
// ── Vendors (per-account snapshots + sales) ───────────────────────────────
@Test fun vendorsLoadsAccountsThenPerAccountSnapshots() {
api.accounts = listOf(ShardLinkDto(account = "acct1"))
api.vendors = VendorSnapshotDto(acct = "acct1", vendors = listOf(VendorDto(serial = "0x9", shopName = "Wares")))
api.sales = listOf(VendorSaleDto(itemType = "sword", price = 100))
val vm = VendorsViewModel(repository)
val accounts = vm.state.value.accounts
assertTrue(accounts is UiState.Success)
assertEquals(listOf("acct1"), (accounts as UiState.Success).data)
// Each account's vendors loaded into the per-account map.
val perAccount = vm.state.value.vendors["acct1"]
assertTrue(perAccount is UiState.Success)
assertEquals("Wares", (perAccount as UiState.Success).data.first().shopName)
assertTrue(vm.state.value.sales is UiState.Success)
}
@Test fun vendorsAccountsErrorSurfacesError() {
api.error = java.io.IOException("offline")
val vm = VendorsViewModel(repository)
assertTrue(vm.state.value.accounts is UiState.Error)
assertTrue(vm.state.value.sales is UiState.Error)
}
}

View File

@@ -0,0 +1,44 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* [FrameFields] does minimal typed reads from a raw SSE frame object for the
* fields a `*.remove` / `*.decay` delta needs; it must return null (not crash) on
* a missing or ill-typed field so a malformed frame is skipped.
*/
class FrameFieldsTest {
@Test fun longFieldParsesNumericPrimitive() {
val obj = buildJsonObject { put("serial", "12345") }
assertEquals(12345L, FrameFields.longField(obj, "serial"))
}
@Test fun longFieldReturnsNullOnMissingOrNonNumeric() {
val obj = buildJsonObject { put("serial", "0xNaN") }
assertNull(FrameFields.longField(obj, "serial")) // not a Long
assertNull(FrameFields.longField(obj, "absent")) // missing key
}
@Test fun longFieldReturnsNullOnJsonNullOrObject() {
val obj = JsonObject(mapOf("a" to JsonNull, "b" to JsonObject(emptyMap())))
assertNull(FrameFields.longField(obj, "a"))
assertNull(FrameFields.longField(obj, "b"))
}
@Test fun stringFieldReadsPrimitiveContent() {
val obj = JsonObject(mapOf("kind" to JsonPrimitive("champ.remove")))
assertEquals("champ.remove", FrameFields.stringField(obj, "kind"))
assertNull(FrameFields.stringField(obj, "missing"))
}
}

View File

@@ -0,0 +1,123 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.api.fake.FakeShardStream
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/**
* The live-board ViewModels (§6.2): a snapshot load kept live by merging SSE
* frames. Tests use a [FakeShardStream] that emits a fixed script and completes,
* so both the snapshot path and the frame-merge path are covered deterministically.
*/
class ShardBoardViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePublicApi()
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true }
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)
// ── Champs: snapshot + live upsert/remove ─────────────────────────────
@Test fun champsSeedsSnapshotAndMergesLiveFrames() {
api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor"))
val stream = FakeShardStream(
listOf(
ShardStreamEvent.Open,
ShardStreamEvent.Frame(
"champ.update",
buildJsonObject { put("serial", "0x2"); put("category", "mini"); put("name", "Barracoon") },
),
ShardStreamEvent.Frame("champ.remove", buildJsonObject { put("serial", "0x1") }),
),
)
val vm = ChampsViewModel(repo(stream))
val rows = (vm.state.value as UiState.Success).data
// 0x1 removed, 0x2 upserted.
assertEquals(listOf("0x2"), rows.map { it.serial })
assertTrue(vm.connected.value) // Open was seen
}
@Test fun champsServerErrorIsUiError() {
api.error = httpError(503)
assertTrue(ChampsViewModel(repo()).state.value is UiState.Error)
}
// ── Guilds ────────────────────────────────────────────────────────────
@Test fun guildsSnapshotThenLiveUpdate() {
api.guilds = listOf(GuildDto(id = 1, name = "Knights"))
val stream = FakeShardStream(
listOf(ShardStreamEvent.Frame("guild.update", buildJsonObject { put("id", 2); put("name", "Mages") })),
)
val vm = GuildsViewModel(repo(stream))
val names = (vm.state.value as UiState.Success).data.map { it.name }
assertTrue(names.contains("Knights"))
assertTrue(names.contains("Mages"))
}
// ── Governors (+ history) ─────────────────────────────────────────────
@Test fun governorsSnapshotAndHistory() {
api.governors = listOf(GovernorDto(city = "Britain"))
api.governorHistory = listOf() // empty is fine
val vm = GovernorsViewModel(repo())
assertTrue(vm.state.value is UiState.Success)
vm.loadHistory("Britain")
assertTrue(vm.history.value.containsKey("Britain"))
}
// ── Houses (IDOC board) ───────────────────────────────────────────────
@Test fun housesSnapshotLoads() {
api.houses = listOf(HouseDto(serial = "0x40", name = "Tower", isIdoc = true))
val vm = HousesViewModel(repo())
assertEquals("Tower", (vm.state.value as UiState.Success).data.first().name)
}
@Test fun housesNetworkErrorIsUiError() {
api.error = java.io.IOException("offline")
assertTrue(HousesViewModel(repo()).state.value is UiState.Error)
}
// ── Shard hub (status primary, presence/online best-effort, live feed) ─
@Test fun shardHubLoadsStatusPresenceOnlineAndSeedsFeed() {
api.shardStatus = ShardStatusDto(enabled = true, pluginConnected = true, onlineCount = 5)
api.shardPresence = PresenceDto(count = 5)
val stream = FakeShardStream(
listOf(
ShardStreamEvent.Closed,
ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }),
),
)
val vm = ShardViewModel(repo(stream))
val hub = (vm.state.value as UiState.Success).data
assertTrue(hub.status.isOnline)
// presence.online frame patched the count in place.
assertEquals(9, hub.presence!!.count)
assertFalse(vm.connected.value) // last lifecycle event was Closed
}
@Test fun shardHubStatusErrorIsUiError() {
api.error = httpError(503)
assertTrue(ShardViewModel(repo()).state.value is UiState.Error)
}
}

View File

@@ -0,0 +1,27 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.util
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.HttpException
import retrofit2.Response
/**
* Test doubles emulate Retrofit's error contract: a suspend API method throws
* [HttpException] on a non-2xx and an [java.io.IOException] on a transport failure,
* exactly what `safeApiCall` folds into `ApiResult.HttpError` / `NetworkError`.
*/
private val JSON = "application/json".toMediaTypeOrNull()
/** An [HttpException] carrying [code] — what a fake API throws to drive an HTTP-error path. */
fun httpError(code: Int): HttpException =
HttpException(Response.error<Any>(code, "{}".toResponseBody(JSON)))
/** A successful bodyless [Response] (for `@DELETE`/moderation endpoints returning `Response<Unit>`). */
fun okUnit(): Response<Unit> = Response.success(Unit)
/** A non-2xx bodyless [Response] with [code]. */
fun errorUnit(code: Int): Response<Unit> = Response.error(code, "{}".toResponseBody(JSON))

View File

@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.util
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* Swaps `Dispatchers.Main` (which `viewModelScope` dispatches on) for a test
* dispatcher for the duration of a test, so ViewModel coroutines run on the test
* scheduler instead of a real Android main looper.
*
* Defaults to an [UnconfinedTestDispatcher] so work launched from a ViewModel's
* `init {}` runs eagerly to its first real suspension — with fakes that never
* truly suspend, that means the final state is settled by the time the constructor
* returns, and a test can assert `state.value` directly without advancing time.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}

View File

@@ -20,13 +20,40 @@ sonar.exclusions=**/build/**,**/.gradle/**,**/generated/**
sonar.sourceEncoding=UTF-8
# ── Optional enrichment (enable once the reports are produced in CI) ──
# For richer Kotlin/Android results, run the reporters in sonarqube.yml and point
# SonarQube at their output:
# ── Coverage (JaCoCo) ──
# sonarqube.yml runs `./gradlew testDebugUnitTest jacocoTestReport` before the scan;
# that task (app/build.gradle.kts) writes this XML. Without it, Sonar reports 0%
# coverage even though the JVM unit suite (app/src/test) exists.
sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml
# Exclude from *coverage* (not from analysis — bugs/smells are still reported): code a
# JVM unit test physically can't execute, so counting its lines would unfairly sink
# new-code coverage. Two kinds: pure-@Composable UI (needs Robolectric/instrumented
# tests), and Android-framework glue (Keystore-backed stores, foreground push service,
# notifications, Hilt modules). Testable logic — ViewModels, repositories, DTOs, and
# pure core/ code — stays measured. See docs/android/COVERAGE_PLAN.md §1.
sonar.coverage.exclusions=\
app/src/main/java/**/ui/**/*Screen.kt,\
app/src/main/java/**/ui/**/*Screen*.kt,\
app/src/main/java/**/ui/theme/**,\
app/src/main/java/**/ui/components/**,\
app/src/main/java/**/ui/page/BlockRenderer.kt,\
app/src/main/java/**/ui/shard/ShardComponents.kt,\
app/src/main/java/**/ui/LocalAssetResolver.kt,\
app/src/main/java/**/RunicApp.kt,\
app/src/main/java/**/MainActivity.kt,\
app/src/main/java/**/*Application.kt,\
app/src/main/java/**/RunicGatewayApp.kt,\
app/src/main/java/**/di/**,\
app/src/main/java/**/core/push/PushService.kt,\
app/src/main/java/**/core/push/PushManager.kt,\
app/src/main/java/**/core/push/PushNotifier.kt,\
app/src/main/java/**/core/push/NtfyStreamClient.kt,\
app/src/main/java/**/core/auth/Encrypted*.kt
# ── Optional enrichment (enable once produced in CI) ──
# • Android Lint: ./gradlew lintDebug → app/build/reports/lint-results-debug.xml
# sonar.androidLint.reportPaths=app/build/reports/lint-results-debug.xml
# • JaCoCo coverage (needs a coverage-enabled test run):
# sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/.../*.xml
# The alternative to the CLI scanner used here is the SonarQube Gradle plugin
# (org.sonarqube), which auto-discovers these reports; the CLI + properties file
# is used instead to keep this repo's setup identical to website/ and link/.