sync: what the on-device round found

Everything here came from running the account flow against a real
Nextcloud rather than from reading the code.

Discovery
- A typed bare origin now gets the RFC 6764 well-known probe. It was
  returned as the only candidate, so `https://cloud.example.com` — what
  people actually type — was PROPFIND'd against the web UI, answered 405,
  and a working Nextcloud reported as "not a CalDAV server".
- A same-host HTTPS→HTTP redirect is put back on TLS instead of refused
  (`dav` change 7). A Nextcloud behind a TLS-terminating proxy without
  `overwriteprotocol` builds every redirect with http://, including the
  /.well-known/caldav hop discovery depends on. Cross-host still throws.
- Outcomes carry a `Cause` the UI translates, not the server's own words.
  "HTTP 405 Method Not Allowed" told someone entering an address nothing,
  in a language they may not read, from outside strings.xml.
- An IPv6 origin keeps its brackets: `HttpUrl.host` returns "fd00::1", so
  the rebuilt origin did not parse and a homelab address came back as
  "not an address".

Login Flow v2
- The poll response's scheme is coerced, never refused. Nextcloud returns
  the app password exactly once, so throwing there burned a live
  credential and left it dangling in the user's device list. The host
  mismatch already worked this way; the scheme now matches it.

Accounts
- The accounts screen observes Room and the sign-in state instead of
  taking a snapshot, so a sync landing — or a 401 stopping an account —
  reaches a screen that is already open.
- A per-account detail screen, and provider identity (`CalDavProvider`)
  shared with the quirk table so one list drives both the icon and the
  warning.
- The password field masks: floret-kit's `InlineTextField` gained a
  visual transformation, since `KeyboardType.Password` only tells the IME
  to drop suggestions.
This commit is contained in:
2026-09-07 18:41:45 +02:00
parent 28b2423ad9
commit b4a7fcb46e
31 changed files with 1455 additions and 280 deletions
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.caldav
import at.bitfire.dav4jvm.DavResource
import at.bitfire.dav4jvm.Response
import at.bitfire.dav4jvm.property.CalendarHomeSet
import at.bitfire.dav4jvm.exception.HttpException
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import java.io.IOException
/**
* RFC 6764 discovery: from what the user typed to the list of task collections.
@@ -75,9 +77,61 @@ class CalDavDiscovery(
/** A 200 whose body says the credentials were not accepted (RFC 5397 §3). */
data object Unauthenticated : Outcome
data class NotCalDav(val reason: String) : Outcome
data class NotCalDav(val cause: Cause, val detail: String) : Outcome
data class Failed(val reason: String) : Outcome
data class Failed(val cause: Cause, val detail: String) : Outcome
/**
* Why discovery ended, in a form the UI can translate.
*
* ⚠️ The UI must render *this*, never [Failed.detail]. A server's own
* words are untranslatable, frequently in a language the user does not
* read, and quite often a bare status line — "HTTP 405 Method Not
* Allowed" tells someone entering their address precisely nothing, and
* bypasses `strings.xml` entirely. [detail] exists for logs and bug
* reports, and is never shown.
*/
enum class Cause {
/** The address is not a URL, or names nothing we can look up. */
NOT_AN_ADDRESS,
/** Reached something, but it does not speak WebDAV at all. */
NOT_A_DAV_SERVER,
/** Speaks WebDAV but not CalDAV — a file-sharing endpoint, say. */
NO_CALENDAR_SUPPORT,
/** Nothing answered: DNS, connection refused, TLS, timeout. */
UNREACHABLE,
/** The address, or where it redirects, is plain HTTP. */
INSECURE,
/** Signed in, but the account exposes no calendar home. */
NO_CALENDARS,
/** The server answered, and the answer was an error of its own. */
SERVER_ERROR,
}
}
/**
* Classifies a transport or protocol failure for the UI.
*
* ⚠️ **405 is the interesting one.** It is what an ordinary web server
* answers to `PROPFIND`, which makes it the single most likely response to
* someone typing their *website* instead of their CalDAV address — and it
* means exactly "this is not a DAV server". Surfacing it as "HTTP 405 Method
* Not Allowed" hands the user a status code where they needed a sentence.
*/
private fun causeOf(error: Throwable): Outcome.Cause = when {
error is HttpException -> when (error.code) {
METHOD_NOT_ALLOWED, NOT_IMPLEMENTED, NOT_FOUND -> Outcome.Cause.NOT_A_DAV_SERVER
else -> Outcome.Cause.SERVER_ERROR
}
// Everything that never got an answer: DNS, refused, TLS, timeout.
error is IOException -> Outcome.Cause.UNREACHABLE
else -> Outcome.Cause.SERVER_ERROR
}
/**
@@ -91,16 +145,18 @@ class CalDavDiscovery(
ServiceDiscovery.asBaseUrl(input)?.let { typed ->
if (!typed.isHttps && !allowCleartext) {
return Outcome.Failed(
"\"$input\" is an unencrypted http:// address. Credentials are never sent " +
"over cleartext, so this can only ever answer \"not authorised\".",
Outcome.Cause.INSECURE,
"\"$input\" is an unencrypted http:// address",
)
}
}
val candidates = ServiceDiscovery.candidatesFor(input, dns)
if (candidates.isEmpty()) return Outcome.Failed("could not read \"$input\" as an address or URL")
if (candidates.isEmpty()) {
return Outcome.Failed(Outcome.Cause.NOT_AN_ADDRESS, "no candidates for \"$input\"")
}
var lastFailure: Outcome = Outcome.Failed("no candidate answered")
var lastFailure: Outcome = Outcome.Failed(Outcome.Cause.UNREACHABLE, "no candidate answered")
for (candidate in candidates) {
when (val outcome = probe(candidate.url)) {
is Outcome.Found, is Outcome.NeedsAuthentication, Outcome.Unauthenticated -> return outcome
@@ -135,7 +191,7 @@ class CalDavDiscovery(
// 401 is not a failure — iCloud and Zoho answer it from
// /.well-known/caldav, which *is* the DAV root, and it is RFC-legal.
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(base.host))
return Outcome.Failed(error.message ?: error.toString())
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
}
// ⚠️ RFC 5397 §3: a 200 carrying <D:unauthenticated/> means the
@@ -148,17 +204,29 @@ class CalDavDiscovery(
val href = principalHref
?: return if (davCapabilities.contains("calendar-access")) {
Outcome.Failed("server advertises calendar-access but returned no principal")
Outcome.Failed(
Outcome.Cause.NO_CALENDAR_SUPPORT,
"advertises calendar-access but returned no principal",
)
} else {
Outcome.NotCalDav("no DAV:current-user-principal, and no calendar-access in OPTIONS")
Outcome.NotCalDav(
Outcome.Cause.NOT_A_DAV_SERVER,
"no DAV:current-user-principal, and no calendar-access in OPTIONS",
)
}
if (davCapabilities.isNotEmpty() && !davCapabilities.contains("calendar-access")) {
return Outcome.NotCalDav("OPTIONS advertises ${davCapabilities.joinToString()} but not calendar-access")
return Outcome.NotCalDav(
Outcome.Cause.NO_CALENDAR_SUPPORT,
"OPTIONS advertises ${davCapabilities.joinToString()} but not calendar-access",
)
}
val principal = resource.location.resolve(href)
?: return Outcome.Failed("principal href \"$href\" is not a usable URL")
?: return Outcome.Failed(
Outcome.Cause.SERVER_ERROR,
"principal href \"$href\" is not a usable URL",
)
return fromPrincipal(principal, movedTo = resource.permanentLocation)
}
@@ -178,10 +246,12 @@ class CalDavDiscovery(
}
homeSetResult.exceptionOrNull()?.let { error ->
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host))
return Outcome.Failed(error.message ?: error.toString())
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
}
if (homeSets.isEmpty()) return Outcome.Failed("principal has no calendar-home-set")
if (homeSets.isEmpty()) {
return Outcome.Failed(Outcome.Cause.NO_CALENDARS, "principal has no calendar-home-set")
}
// Cross-host is legal and required, but never over plain HTTP: the
// credentials follow the home set, and a downgrade would send them in the
@@ -189,7 +259,10 @@ class CalDavDiscovery(
val crossHost = homeSets.filter { it.host != principal.host }
val insecure = homeSets.filter { principal.isHttps && !it.isHttps }
if (insecure.isNotEmpty()) {
return Outcome.Failed("calendar-home-set downgrades to HTTP: ${insecure.first()}")
return Outcome.Failed(
Outcome.Cause.INSECURE,
"calendar-home-set downgrades to HTTP: ${insecure.first()}",
)
}
val collections = linkedMapOf<HttpUrl, TaskCollection>()
@@ -228,6 +301,7 @@ class CalDavDiscovery(
Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct())
} else {
Outcome.Failed(
Outcome.Cause.NO_CALENDARS,
failures.firstOrNull()?.reason ?: "no calendar-home-set could be listed",
)
}
@@ -249,5 +323,9 @@ class CalDavDiscovery(
companion object {
/** `https://host/path` → the URL, or null. Convenience for callers. */
fun url(value: String): HttpUrl? = value.toHttpUrlOrNull()
private const val NOT_FOUND = 404
private const val METHOD_NOT_ALLOWED = 405
private const val NOT_IMPLEMENTED = 501
}
}
@@ -0,0 +1,91 @@
package de.jeanlucmakiola.caldav
import okhttp3.HttpUrl
/**
* Which CalDAV service or server software an account talks to.
*
* Read off the two things an account already stores, so nothing extra is asked
* of the server and no column has to be added: the **host**, which names a
* hosted service outright, and the **principal URL's path**, whose shape is a
* fingerprint of the software behind it.
*
* [hosted] is what decides how an account is *named* on screen. A hosted
* service's host is boilerplate (`caldav.fastmail.com` for everyone), so its
* brand is the name; self-hosted software runs on the user's own host, which is
* the only thing that tells two of them apart.
*/
enum class CalDavProvider(
val label: String,
val hosted: Boolean,
internal val domains: Set<String> = emptySet(),
internal val principalMarkers: Set<String> = emptySet(),
) {
/**
* ownCloud serves `/remote.php/dav/` too and cannot be told apart from here.
* Nextcloud is the far commoner of the two and the only one the add flow has
* a browser login for, so the mark goes to it — and because a self-hosted
* account is titled by its host, the name "Nextcloud" is never written next
* to an ownCloud server, only its mark.
*/
NEXTCLOUD("Nextcloud", hosted = false, principalMarkers = setOf("/remote.php/dav/")),
BAIKAL("Baïkal", hosted = false, principalMarkers = setOf("/dav.php/")),
DAVICAL("DAViCal", hosted = false, principalMarkers = setOf("/caldav.php/")),
SOGO("SOGo", hosted = false, principalMarkers = setOf("/sogo/dav/")),
FASTMAIL(
"Fastmail",
hosted = true,
domains = setOf("fastmail.com", "fastmail.fm", "messagingengine.com"),
),
ICLOUD("iCloud", hosted = true, domains = setOf("icloud.com", "me.com", "mac.com")),
GOOGLE("Google", hosted = true, domains = setOf("gmail.com", "googlemail.com", "google.com")),
MAILBOX_ORG("mailbox.org", hosted = true, domains = setOf("mailbox.org")),
POSTEO("Posteo", hosted = true, domains = setOf("posteo.de", "posteo.net")),
ZOHO("Zoho", hosted = true, domains = setOf("zoho.com", "zoho.eu")),
YANDEX("Yandex", hosted = true, domains = setOf("yandex.ru", "yandex.com")),
;
companion object {
/** The service a host belongs to, if it is one we know by name. */
fun forHost(host: String): CalDavProvider? {
val lower = host.lowercase().trimEnd('.')
return entries.firstOrNull { provider ->
provider.domains.any { lower == it || lower.endsWith(".$it") }
}
}
/** The service behind an email address or a typed URL, if any. */
fun forInput(input: String): CalDavProvider? {
val host = ServiceDiscovery.asBaseUrl(input)?.host
?: ServiceDiscovery.domainOf(input)
?: return null
return forHost(host)
}
/**
* The provider behind a principal URL: the host first, because a service
* we know by name is not in doubt, then the path shape.
*/
fun forPrincipal(url: HttpUrl): CalDavProvider? =
forHost(url.host) ?: forPath(url.encodedPath)
private fun forPath(path: String): CalDavProvider? {
val lower = path.lowercase()
return entries.firstOrNull { provider ->
provider.principalMarkers.any { it in lower }
}
}
}
}
@@ -162,14 +162,14 @@ class NextcloudLoginFlow(
val root = json.parseToJsonElement(body).jsonObject
val server = root["server"]?.jsonPrimitive?.content?.toHttpUrlOrNull()
?: error("no server URL in poll response")
// Scheme only. A host mismatch here must never discard the credentials:
// the 200 is returned exactly once — the server deletes the row inside
// poll() before returning — so throwing would burn the app password and
// force the user through the whole flow again.
requireSecureOrigin(flow.pollEndpoint, server)
// ⚠️ Coerced, never refused — neither the host nor the scheme may discard
// the credentials. The 200 is returned exactly once (the server deletes
// the row inside poll() before answering), so a throw here burns a live
// app password and leaves it dangling in the user's device list.
val secureServer = secureOrigin(flow.pollEndpoint, server)
PollResult.Approved(
Credentials(
server = server,
server = secureServer,
// ⚠️ loginName is what the user typed — possibly an email, an
// LDAP-derived value, or the right name in the wrong case. It is
// the Basic auth username and nothing else. Interpolating it into
@@ -182,17 +182,14 @@ class NextcloudLoginFlow(
}.getOrElse { PollResult.Failed(it.message ?: it.toString()) }
/**
* The endpoint is generated from `overwrite.cli.url` / `overwriteprotocol` /
* These URLs are generated from `overwrite.cli.url` / `overwriteprotocol` /
* `trusted_proxies`, which are misconfigured on a large fraction of
* self-hosted installs — so it is validated rather than trusted verbatim.
* self-hosted installs — so they are validated rather than trusted verbatim.
*
* A downgrade to `http` is refused outright: the poll token is exchanged for a
* long-lived app password, which makes it a credential-grade secret.
*/
/**
* A downgrade to `http` is **fatal**. The poll token is exchanged for a
* long-lived app password and the login URL takes the account password, so
* both are credential-grade.
* A downgrade to `http` is **fatal here and only here**: this runs *before*
* the user has approved anything, and the login URL is where they type their
* account password. There is nothing to lose by refusing, and everything to
* lose by not.
*/
internal fun requireSecureOrigin(expected: HttpUrl, actual: HttpUrl) {
if (expected.isHttps && !actual.isHttps) {
@@ -200,6 +197,29 @@ class NextcloudLoginFlow(
}
}
/**
* The same downgrade, on the *poll response*, where refusing is the wrong
* answer.
*
* ⚠️ By this point the credential has already been issued, and Nextcloud
* returns it **exactly once** — the row is deleted inside `poll()` before it
* answers. Throwing here does not protect anything: it destroys a live app
* password, leaves one dangling in the user's device list, and sends them
* through the whole flow again. The comment on the host mismatch above says
* precisely this, and the scheme deserves the same treatment.
*
* Coercing is strictly safer than either alternative, because the invariant
* that matters is *what we do next*: we only ever talk to the coerced URL, so
* the credential never travels in cleartext regardless of what the server
* put in the JSON.
*/
internal fun secureOrigin(expected: HttpUrl, actual: HttpUrl): HttpUrl =
if (expected.isHttps && !actual.isHttps) {
actual.newBuilder().scheme("https").build()
} else {
actual
}
/** A different host than the user typed — reported, not refused. */
internal fun hostMismatchOf(expected: HttpUrl, actual: HttpUrl): HostMismatch? =
if (expected.host != actual.host) HostMismatch(expected.host, actual.host) else null
@@ -9,14 +9,17 @@ import okhttp3.HttpUrl
* account password"* is the single most common support ticket any CalDAV client
* inherits. Detecting it at account-add time by domain turns a dead end into one
* sentence of instruction.
*
* The domains themselves live on [CalDavProvider] — one list, so a provider the
* accounts screen can mark is also a provider this can warn about.
*/
enum class ServerQuirk(val domains: Set<String>) {
enum class ServerQuirk {
/** Fastmail: needs an app password, and CalDAV is not on the Basic plan. */
FASTMAIL_APP_PASSWORD(setOf("fastmail.com", "fastmail.fm", "messagingengine.com")),
FASTMAIL_APP_PASSWORD,
/** iCloud: app-specific password, and 2FA must be on to mint one. */
ICLOUD_APP_SPECIFIC_PASSWORD(setOf("icloud.com", "me.com", "mac.com")),
ICLOUD_APP_SPECIFIC_PASSWORD,
/**
* Google: OAuth2-only, and it supports neither VTODO nor MKCALENDAR — its own
@@ -24,7 +27,7 @@ enum class ServerQuirk(val domains: Set<String>) {
* drops it as a target, so this is a refusal with an explanation rather than
* a 401 the user cannot act on.
*/
GOOGLE_UNSUPPORTED(setOf("gmail.com", "googlemail.com", "google.com")),
GOOGLE_UNSUPPORTED,
/**
* Nextcloud's brute-force protection throttles then 429s **per source IP**, so
@@ -32,27 +35,26 @@ enum class ServerQuirk(val domains: Set<String>) {
* clients on that network. Not domain-detectable; set when a server identifies
* itself. Kept here so the engine has one place to ask.
*/
NEXTCLOUD_BRUTE_FORCE_PROTECTED(emptySet()),
NEXTCLOUD_BRUTE_FORCE_PROTECTED,
;
companion object {
/** The quirk implied by an email address or a URL host, if any. */
fun forInput(input: String): ServerQuirk? {
val host = ServiceDiscovery.asBaseUrl(input)?.host
?: ServiceDiscovery.domainOf(input)
?: return null
return forHost(host)
}
fun forInput(input: String): ServerQuirk? = CalDavProvider.forInput(input)?.quirk
fun forHost(host: String): ServerQuirk? {
val lower = host.lowercase().trimEnd('.')
return entries.firstOrNull { quirk ->
quirk.domains.any { lower == it || lower.endsWith(".$it") }
}
}
fun forHost(host: String): ServerQuirk? = CalDavProvider.forHost(host)?.quirk
fun forUrl(url: HttpUrl): ServerQuirk? = forHost(url.host)
private val CalDavProvider.quirk: ServerQuirk?
get() = when (this) {
CalDavProvider.FASTMAIL -> FASTMAIL_APP_PASSWORD
CalDavProvider.ICLOUD -> ICLOUD_APP_SPECIFIC_PASSWORD
CalDavProvider.GOOGLE -> GOOGLE_UNSUPPORTED
CalDavProvider.NEXTCLOUD -> NEXTCLOUD_BRUTE_FORCE_PROTECTED
else -> null
}
}
/** True when discovery should not even be attempted. */
@@ -71,9 +71,43 @@ object ServiceDiscovery {
val trimmed = input.trim()
if (trimmed.isEmpty()) return emptyList()
// A typed base URL is used as typed. PROPFIND on it can return principal,
// home-set and collection in one response, so DNS is never consulted.
asBaseUrl(trimmed)?.let { return listOf(Candidate(it, "base URL as typed")) }
// A typed URL is taken as typed and DNS is never consulted — a user who
// gave us an address meant it.
asBaseUrl(trimmed)?.let { typed ->
val candidates = mutableListOf<Candidate>()
// ⚠️ A typed URL is not automatically a *DAV* URL, and the bare
// origin is what people actually type. `https://cloud.example.com`
// is the web UI: PROPFIND on it returns the 405 any web server
// answers, which reads as "not a CalDAV server" about a perfectly
// good one. RFC 6764 §6 exists precisely for this case, so the
// well-known probe has to run here too — returning the typed URL as
// the only candidate is what made a working Nextcloud undiscoverable.
//
// ⚠️ Built through `newBuilder`, not by interpolating `host`, which
// returns an IPv6 literal *without* its brackets — "fd00::1", not
// "[fd00::1]". Pasting that back into a URL yields a string OkHttp
// will not parse, so both candidates would be dropped and a typed
// IPv6 address would report as "not an address". `toString()` also
// drops a default port on its own.
val origin = typed.newBuilder()
.encodedPath("/")
.query(null)
.fragment(null)
.build()
.toString()
if (typed.encodedPath.trim('/').isEmpty()) {
add(candidates, origin, WELL_KNOWN, "typed origin + .well-known")
add(candidates, origin, "/", "typed origin + root")
} else {
// A deep URL may well be the DAV root itself, and PROPFIND on it
// can return principal, home-set and collection in one response.
// The well-known stays as the fallback for a path that was a
// guess.
candidates += Candidate(typed, "base URL as typed")
add(candidates, origin, WELL_KNOWN, "typed host + .well-known")
}
return candidates
}
val domain = domainOf(trimmed) ?: return emptyList()
val candidates = mutableListOf<Candidate>()
@@ -191,7 +191,10 @@ class CalDavDiscoveryTest {
// answer "not authorised" — which the user reads as a wrong password.
val outcome = discovery.discover("http://cloud.example.com/dav/")
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java)
assertThat((outcome as CalDavDiscovery.Outcome.Failed).reason).contains("http://")
val failed = outcome as CalDavDiscovery.Outcome.Failed
// The cause is what the UI renders; the detail is for logs only.
assertThat(failed.cause).isEqualTo(CalDavDiscovery.Outcome.Cause.INSECURE)
assertThat(failed.detail).contains("http://")
}
@Test
@@ -0,0 +1,42 @@
package de.jeanlucmakiola.caldav
import com.google.common.truth.Truth.assertThat
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Test
class CalDavProviderTest {
@Test
fun `a hosted service is known by its host`() {
assertThat(CalDavProvider.forInput("me@fastmail.com")).isEqualTo(CalDavProvider.FASTMAIL)
assertThat(CalDavProvider.forHost("caldav.icloud.com")).isEqualTo(CalDavProvider.ICLOUD)
assertThat(CalDavProvider.forHost("dav.mailbox.org")).isEqualTo(CalDavProvider.MAILBOX_ORG)
assertThat(CalDavProvider.forHost("cloud.example.de")).isNull()
}
@Test
fun `self-hosted software is known by its principal path`() {
val nextcloud = "https://cloud.example.de/remote.php/dav/principals/users/jo/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(nextcloud)).isEqualTo(CalDavProvider.NEXTCLOUD)
val baikal = "https://dav.example.de/dav.php/principals/jo/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(baikal)).isEqualTo(CalDavProvider.BAIKAL)
val unknown = "https://dav.example.de/jo/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(unknown)).isNull()
}
@Test
fun `the host wins over the path`() {
// Fastmail's principals sit under a path we know nothing about; the host
// already named the service, so nothing else is consulted.
val url = "https://caldav.fastmail.com/dav/principals/user/me@fastmail.com/".toHttpUrl()
assertThat(CalDavProvider.forPrincipal(url)).isEqualTo(CalDavProvider.FASTMAIL)
}
@Test
fun `hosted services are titled by brand, self-hosted ones by host`() {
assertThat(CalDavProvider.FASTMAIL.hosted).isTrue()
assertThat(CalDavProvider.NEXTCLOUD.hosted).isFalse()
}
}
@@ -166,4 +166,42 @@ class NextcloudLoginFlowTest {
assertThat(mismatch!!.actual).isEqualTo("cloud.example.com")
assertThat(mismatch.message).contains("overwrite.cli.url")
}
@Test fun `a downgraded server URL in the poll response is coerced, not refused`() {
// ⚠️ The credential has already been issued and the 200 comes exactly
// once — the row is deleted inside poll() before it answers. Throwing
// here destroys a live app password and leaves it dangling in the user's
// device list, for no protection at all.
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
val downgraded = "http://cloud.example.com".toHttpUrl()
val coerced = flow.secureOrigin(expected, downgraded)
assertThat(coerced.scheme).isEqualTo("https")
assertThat(coerced.host).isEqualTo("cloud.example.com")
}
@Test fun `an already-secure server URL is left alone`() {
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
val actual = "https://dav.example.com".toHttpUrl()
// A different host is reported by hostMismatchOf, never rewritten here.
assertThat(flow.secureOrigin(expected, actual)).isEqualTo(actual)
}
@Test fun `the login URL is still refused outright when downgraded`() {
// Before approval there is nothing to lose by refusing, and the login URL
// is where the *account* password gets typed.
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
val failure = runCatching {
flow.requireSecureOrigin(
"https://cloud.example.com".toHttpUrl(),
"http://cloud.example.com/login".toHttpUrl(),
)
}.exceptionOrNull()
assertThat(failure).isNotNull()
}
}
@@ -24,8 +24,19 @@ class ServiceDiscoveryTest {
@Test
fun `a typed base URL is used as typed and never triggers DNS`() {
val dns = FakeDns(srv = mapOf("_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 8443, "dav.example.com"))))
assertThat(urls("https://cloud.example.com/remote.php/dav/", dns))
.containsExactly("https://cloud.example.com/remote.php/dav/")
val candidates = urls("https://cloud.example.com/remote.php/dav/", dns)
// The typed path is tried first and the SRV target is never consulted:
// a user who gave us an address meant it.
assertThat(candidates.first()).isEqualTo("https://cloud.example.com/remote.php/dav/")
assertThat(candidates).doesNotContain("https://dav.example.com:8443/")
// The well-known follows as a fallback, because a typed path may have
// been a guess — see `a typed deep URL is tried as typed, first`.
assertThat(candidates)
.containsExactly(
"https://cloud.example.com/remote.php/dav/",
"https://cloud.example.com/.well-known/caldav",
).inOrder()
}
@Test
@@ -125,4 +136,59 @@ class ServiceDiscoveryTest {
assertThat(ServiceDiscovery.candidatesFor("me@gmx.net", dns).map { it.origin })
.contains("domain as typed + TXT path=/begenda/dav/users/")
}
// --- a typed URL still gets the RFC 6764 probe -------------------------
@Test fun `a typed bare origin still tries well-known`() {
// ⚠️ The case every user actually types. The origin is the *web UI*, and
// PROPFIND on it returns the 405 any web server answers — which reads as
// "not a CalDAV server" about a working Nextcloud. Found against a real
// server, not by reading.
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com")
.map { it.url.encodedPath }
assertThat(paths).containsExactly("/.well-known/caldav", "/").inOrder()
}
@Test fun `a trailing slash is still a bare origin`() {
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com/")
.map { it.url.encodedPath }
assertThat(paths).containsExactly("/.well-known/caldav", "/").inOrder()
}
@Test fun `a typed deep URL is tried as typed, first`() {
// A deep URL may be the DAV root itself, where one PROPFIND can return
// principal, home-set and collection together.
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com/remote.php/dav/")
.map { it.url.encodedPath }
assertThat(paths.first()).isEqualTo("/remote.php/dav/")
// …but the path may have been a guess, so the probe stays as a fallback.
assertThat(paths).contains("/.well-known/caldav")
}
@Test fun `a non-default port survives the origin rebuild`() {
val candidates = ServiceDiscovery.candidatesFor("https://cloud.example.com:8443")
assertThat(candidates.map { it.url.toString() })
.containsExactly(
"https://cloud.example.com:8443/.well-known/caldav",
"https://cloud.example.com:8443/",
).inOrder()
}
@Test fun `an IPv6 literal keeps its brackets through the origin rebuild`() {
// ⚠️ `HttpUrl.host` hands back "fd00::1", not "[fd00::1]", so an origin
// built by interpolating it is a string OkHttp will not parse — both
// candidates were silently dropped and a homelab address reported as
// "not an address".
val candidates = ServiceDiscovery.candidatesFor("https://[fd00::1]:8443")
assertThat(candidates.map { it.url.toString() })
.containsExactly(
"https://[fd00::1]:8443/.well-known/caldav",
"https://[fd00::1]:8443/",
).inOrder()
}
}