Release 1.0.0 (#20)
First stable release. Merging this bumps versionName to 1.0.0 and triggers the release pipeline (F-Droid, Codeberg, Play). **App** - CalDAV sync built in, with Agendula's own task store; OpenTasks / tasks.org stay available and can be copied over in Settings → Storage - repeating tasks, several reminders per task, lists managed in the app, iCalendar import/export, widget and Quick Settings tile - a list can be kept out of the smart lists (#18) and gets its own notification channel (#17) - duplicate a task with its subtasks (#16) - HTML descriptions shown as plain text (#15) - relative day words in reminder notifications (#14) - asks for exact-alarm access instead of claiming USE_EXACT_ALARM, and re-arms reminders when that access changes **Release plumbing** - floret-kit bumped to v0.4.0; the old pin was never pushed, so a clean clone couldn't check out the submodule. 0.4.0 drops CrashConfig.issueTitle (crash issues are always filed in English) - prebuilt .so files ship unstripped, so the build no longer depends on whether an NDK is installed; now checked by check_reproducible_release.sh - official F-Droid recipe in docs/fdroid-official/, to submit to fdroiddata once v1.0.0 is tagged - Google Play: fastlane uploads the AAB and every locale's What's New after the F-Droid release; a separate listing lane pushes text and graphics from the fastlane tree, which CI now checks against Play's limits - store listing: title "Agendula: Tasks" in every locale, icon, feature graphic, screenshots and 1.0.0 changelogs in en-US, en-GB, de-DE and pt-BR crash_report_issue_title is now unused but stays until Weblate removes the translated copies. Closes #14, closes #15, closes #16, closes #17, closes #18 Co-authored-by: Jean-Luc Makiola <business@jeanlucmakiola.de> Reviewed-on: https://codeberg.org/jlmakiola/agendula/pulls/20
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
plugins {
|
||||
// No version — AGP already puts the Kotlin plugin on the build classpath.
|
||||
id("org.jetbrains.kotlin.jvm")
|
||||
}
|
||||
|
||||
// Our CalDAV protocol layer: discovery, auth, Nextcloud Login Flow v2. MIT, and
|
||||
// deliberately a separate module from the MPL-2.0 vendored `:dav`.
|
||||
//
|
||||
// A plain JVM module, like `:dav`, and for the same two reasons: it enforces "no
|
||||
// Android types" at compile time rather than by discipline, and it lets the whole
|
||||
// protocol layer be tested against MockWebServer on the JVM. Anything that needs
|
||||
// the platform — Keystore, AccountManager, Custom Tabs — belongs in `:app`.
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlin {
|
||||
compilerOptions {
|
||||
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":dav"))
|
||||
implementation(libs.dnsjava)
|
||||
// Runtime API only (Json.parseToJsonElement) — no @Serializable, so the
|
||||
// serialization compiler plugin is not needed here.
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
compileOnly(libs.xpp3)
|
||||
testImplementation(libs.xpp3)
|
||||
testImplementation(libs.junit4)
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.truth)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.IOException
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
/**
|
||||
* Gives an app password back when the account is removed.
|
||||
*
|
||||
* ⚠️ Without this, **uninstalling never revokes access**. Nextcloud's Login Flow
|
||||
* v2 mints a device-specific password that survives the app entirely: it stays
|
||||
* listed under Settings → Security → Devices & sessions until the user notices
|
||||
* and deletes it by hand, on an entry named after an app that is no longer
|
||||
* installed. Minting a credential and then abandoning it is not an acceptable
|
||||
* end state for a client that asked for one.
|
||||
*
|
||||
* Best-effort by design. The account is being removed either way, and a server
|
||||
* that is unreachable, or was never a Nextcloud, must not block that.
|
||||
*/
|
||||
object AppPassword {
|
||||
|
||||
/** Nextcloud's OCS endpoint for "delete the password I authenticated with". */
|
||||
private const val PATH = "ocs/v2.php/core/apppassword"
|
||||
|
||||
/**
|
||||
* Derives the OCS root from a Nextcloud principal URL.
|
||||
*
|
||||
* ⚠️ The principal URL is **not** the server root, and appending to it is the
|
||||
* bug this function exists to prevent: a principal is
|
||||
* `…/remote.php/dav/principals/users/alice/`, so
|
||||
* `principal + "ocs/v2.php/…"` produces a path that 404s on every server,
|
||||
* every time, silently — the revocation reads as "attempted" and does
|
||||
* nothing at all.
|
||||
*
|
||||
* Nextcloud mounts WebDAV under `remote.php`, so everything before that
|
||||
* segment is the server root, and that is true of a subpath install
|
||||
* (`https://host/nextcloud/`) as much as of a root one. Falling back to the
|
||||
* origin is right for a server that does not use `remote.php` — it is not a
|
||||
* Nextcloud, so the endpoint does not exist there under any path.
|
||||
*/
|
||||
fun ocsRootFor(principal: HttpUrl): HttpUrl {
|
||||
val mount = principal.pathSegments.indexOfFirst { it.equals("remote.php", ignoreCase = true) }
|
||||
// No `remote.php` means this is not a Nextcloud layout, and guessing a
|
||||
// prefix from an arbitrary DAV path would aim the DELETE somewhere
|
||||
// unrelated. The origin is the only defensible answer, and on a server
|
||||
// without the endpoint it simply 404s.
|
||||
val prefix = if (mount < 0) emptyList() else principal.pathSegments.take(mount)
|
||||
return principal.newBuilder()
|
||||
.encodedPath("/")
|
||||
.apply {
|
||||
prefix.filter { it.isNotEmpty() }.forEach { addPathSegment(it) }
|
||||
// A trailing empty segment keeps this a directory URL, so
|
||||
// appending the OCS path cannot fuse onto the last segment.
|
||||
if (prefix.any { it.isNotEmpty() }) addPathSegment("")
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The budget is enforced here because nothing above can enforce it.
|
||||
* `execute()` parks on a socket read, which no coroutine cancellation and no
|
||||
* `Thread.interrupt` can break — only closing the socket does, which is what
|
||||
* `callTimeout` does. Wrapping this call in `withTimeoutOrNull` instead
|
||||
* returns only once the read has finished anyway, so the caller waits out
|
||||
* the shared client's own budget: 30s *per resolved address*, doubled by the
|
||||
* authenticator's retry, plus up to 120s of read timeout. Minutes, for a
|
||||
* step whose own doc says it must not block the removal.
|
||||
*
|
||||
* The shared client has a ceiling of its own, but it is sized for a
|
||||
* multiget of a full batch over a slow link — minutes, where a courtesy
|
||||
* revocation the user is waiting behind gets seconds.
|
||||
*
|
||||
* @return true when the server confirmed the revocation. False means the
|
||||
* credential may still exist server-side — the caller carries on regardless.
|
||||
*/
|
||||
fun revoke(
|
||||
httpClient: OkHttpClient,
|
||||
principal: HttpUrl,
|
||||
timeout: Duration = REVOCATION_TIMEOUT,
|
||||
): Boolean = revokeAt(httpClient, ocsRootFor(principal), timeout)
|
||||
|
||||
/**
|
||||
* The same revocation, for a caller that already holds the server root.
|
||||
*
|
||||
* ⚠️ Do not route such a caller through [revoke]. [ocsRootFor] is
|
||||
* *principal*-shaped: it looks for `remote.php` and falls back to the bare
|
||||
* origin when there is none. A login flow hands back a server base, so a
|
||||
* subpath install's `https://host/nextcloud/` would collapse to
|
||||
* `https://host/` and the DELETE would 404 on every one of them — attempted,
|
||||
* and doing nothing, which is the failure [ocsRootFor] exists to prevent.
|
||||
*/
|
||||
fun revokeAt(
|
||||
httpClient: OkHttpClient,
|
||||
ocsRoot: HttpUrl,
|
||||
timeout: Duration = REVOCATION_TIMEOUT,
|
||||
): Boolean = try {
|
||||
val url = ocsRoot.newBuilder().addPathSegments(PATH).build()
|
||||
Redirects.follow(
|
||||
httpClient.newBuilder()
|
||||
// Shares the pool and dispatcher, so this costs nothing.
|
||||
.callTimeout(timeout.toJavaDuration())
|
||||
.build(),
|
||||
Request.Builder()
|
||||
.url(url)
|
||||
.delete()
|
||||
// ⚠️ Not optional. Without this header Nextcloud answers the OCS
|
||||
// API with a 401 and a CSRF complaint rather than doing the work,
|
||||
// which reads exactly like a wrong password.
|
||||
.header("OCS-APIRequest", "true")
|
||||
.header("Accept", "application/json")
|
||||
.build(),
|
||||
).use { it.isSuccessful }
|
||||
} catch (_: IOException) {
|
||||
// callTimeout throws InterruptedIOException, which lands here.
|
||||
false
|
||||
} catch (_: IllegalArgumentException) {
|
||||
false
|
||||
}
|
||||
|
||||
/** What a best-effort courtesy call is worth waiting for. */
|
||||
val REVOCATION_TIMEOUT: Duration = 5.seconds
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
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.
|
||||
*
|
||||
* The happy path is five requests. Everything else here is a rule that exists
|
||||
* because a real server broke the obvious implementation.
|
||||
*/
|
||||
class CalDavDiscovery(
|
||||
private val httpClient: OkHttpClient,
|
||||
private val dns: DnsResolver = DnsResolver.None,
|
||||
/**
|
||||
* Whether a typed `http://` base URL may be probed.
|
||||
*
|
||||
* Off by default. Credentials are never sent over cleartext — the
|
||||
* interceptor withholds them — so an http:// server would otherwise answer
|
||||
* 401 forever and the user would be told their password was wrong when the
|
||||
* real problem is the scheme. Any escape hatch has to be a narrow, warned,
|
||||
* per-account opt-in; this is that switch.
|
||||
*/
|
||||
private val allowCleartext: Boolean = false,
|
||||
) {
|
||||
|
||||
/**
|
||||
* Properties requested **by name**, because allprop legitimately omits them.
|
||||
*
|
||||
* Owned by [CollectionClassifier], which is what reads them back — a second
|
||||
* copy here would drift the day one of them is added.
|
||||
*/
|
||||
private val collectionProperties = CollectionClassifier.PROPERTIES
|
||||
|
||||
/** A home set that could not be listed. One failing must not hide the others. */
|
||||
data class HomeSetFailure(
|
||||
val url: HttpUrl,
|
||||
val reason: String,
|
||||
val needsAuthentication: Boolean,
|
||||
)
|
||||
|
||||
sealed interface Outcome {
|
||||
data class Found(
|
||||
val principal: HttpUrl,
|
||||
val collections: List<TaskCollection>,
|
||||
/** Every `calendar-home-set` href, in the order the principal listed them. */
|
||||
val homeSets: List<HttpUrl>,
|
||||
/** Set when a 301/308 moved us; the caller must persist it. */
|
||||
val movedTo: HttpUrl?,
|
||||
/** Home sets on a different host than the principal. Normative, but worth surfacing. */
|
||||
val crossHostHomeSets: List<HttpUrl>,
|
||||
/** Home sets that could not be read. The rest of the result is still good. */
|
||||
val failedHomeSets: List<HomeSetFailure> = emptyList(),
|
||||
) : Outcome
|
||||
|
||||
/**
|
||||
* The server wants credentials. Not a failure — authenticate and retry.
|
||||
*
|
||||
* [hosts] names *which* hosts asked, so the caller can say which one it
|
||||
* could not reach. A cross-host home set under the same registrable
|
||||
* domain — iCloud puts the principal on `caldav.icloud.com` and the home
|
||||
* set on `pNN-caldav.icloud.com` — is already covered, because that is
|
||||
* the scope `CalDavHttp` gives the credential.
|
||||
*
|
||||
* ⚠️ What is *not* covered is a home set on a genuinely different
|
||||
* registrable domain, which RFC 4791 §6.2.1 allows. Nothing widens the
|
||||
* allowlist for it and nothing should without the user's say-so: the
|
||||
* password would be offered to a host named by the first server, and one
|
||||
* credential scope is what makes that decidable. The add flow reports
|
||||
* such a host by name instead of pretending the account is empty.
|
||||
*/
|
||||
data class NeedsAuthentication(val hosts: List<String>) : Outcome
|
||||
|
||||
/** A 200 whose body says the credentials were not accepted (RFC 5397 §3). */
|
||||
data object Unauthenticated : Outcome
|
||||
|
||||
data class NotCalDav(val cause: Cause, val detail: 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks every candidate for [input] and returns the first real result.
|
||||
*
|
||||
* A 401 stops the walk immediately: it means we found a DAV server and simply
|
||||
* have no credentials for it, and continuing down the ladder would replace a
|
||||
* precise "sign in" with a vague "nothing found".
|
||||
*/
|
||||
fun discover(input: String): Outcome {
|
||||
ServiceDiscovery.asBaseUrl(input)?.let { typed ->
|
||||
if (!typed.isHttps && !allowCleartext) {
|
||||
return Outcome.Failed(
|
||||
Outcome.Cause.INSECURE,
|
||||
"\"$input\" is an unencrypted http:// address",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val candidates = ServiceDiscovery.candidatesFor(input, dns)
|
||||
if (candidates.isEmpty()) {
|
||||
return Outcome.Failed(Outcome.Cause.NOT_AN_ADDRESS, "no candidates for \"$input\"")
|
||||
}
|
||||
|
||||
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
|
||||
else -> lastFailure = outcome
|
||||
}
|
||||
}
|
||||
return lastFailure
|
||||
}
|
||||
|
||||
internal fun probe(base: HttpUrl): Outcome {
|
||||
val resource = DavResource(httpClient, base)
|
||||
|
||||
// The OPTIONS gate. `DAV: calendar-access` is what distinguishes a CalDAV
|
||||
// server from any other WebDAV host, and it is what keeps Google's SRV
|
||||
// record — which points at something that answers 405 to PROPFIND — from
|
||||
// looking like a discovery that merely found no calendars.
|
||||
var davCapabilities: Set<String> = emptySet()
|
||||
runCatching { resource.options { capabilities, _ -> davCapabilities = capabilities } }
|
||||
|
||||
var principalHref: String? = null
|
||||
var unauthenticated = false
|
||||
val propfindResult = runCatching {
|
||||
resource.propfind(0, CurrentUserPrincipal.NAME) { response, _ ->
|
||||
response[CurrentUserPrincipal::class.java]?.let {
|
||||
principalHref = it.href
|
||||
unauthenticated = unauthenticated || it.unauthenticated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
propfindResult.exceptionOrNull()?.let { error ->
|
||||
// 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(causeOf(error), error.message ?: error.toString())
|
||||
}
|
||||
|
||||
// ⚠️ RFC 5397 §3: a 200 carrying <D:unauthenticated/> means the
|
||||
// credentials were rejected. Without this check a failed login looks like
|
||||
// a successful discovery that found nothing — the shape of bug report
|
||||
// nobody can act on. The element is parsed explicitly (dav/PROVENANCE.md
|
||||
// change 6) rather than inferred from a null href, which would also fire
|
||||
// for a merely non-conformant empty element.
|
||||
if (unauthenticated) return Outcome.Unauthenticated
|
||||
|
||||
val href = principalHref
|
||||
?: return if (davCapabilities.contains("calendar-access")) {
|
||||
Outcome.Failed(
|
||||
Outcome.Cause.NO_CALENDAR_SUPPORT,
|
||||
"advertises calendar-access but returned no principal",
|
||||
)
|
||||
} else {
|
||||
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(
|
||||
Outcome.Cause.NO_CALENDAR_SUPPORT,
|
||||
"OPTIONS advertises ${davCapabilities.joinToString()} but not calendar-access",
|
||||
)
|
||||
}
|
||||
|
||||
val principal = resource.location.resolve(href)
|
||||
?: return Outcome.Failed(
|
||||
Outcome.Cause.SERVER_ERROR,
|
||||
"principal href \"$href\" is not a usable URL",
|
||||
)
|
||||
|
||||
return fromPrincipal(principal, movedTo = resource.permanentLocation)
|
||||
}
|
||||
|
||||
internal fun fromPrincipal(principal: HttpUrl, movedTo: HttpUrl? = null): Outcome {
|
||||
val homeSets = mutableListOf<HttpUrl>()
|
||||
val principalResource = DavResource(httpClient, principal)
|
||||
val homeSetResult = runCatching {
|
||||
principalResource.propfind(0, CalendarHomeSet.NAME) { response, _ ->
|
||||
// ⚠️ Iterate ALL hrefs. Multiple home sets are normative (RFC 4791
|
||||
// §6.2.1's own example) and iCloud depends on it: the principal is
|
||||
// on caldav.icloud.com and the home set on pNN-caldav.icloud.com.
|
||||
// ⚠️ Against where the PROPFIND *landed*, not where it was
|
||||
// aimed. `followRedirects` rewrites `location` in place, and the
|
||||
// probe above already reads it back for exactly this reason. A
|
||||
// principal that has permanently moved answers a relative
|
||||
// `<href>calendars/alice/</href>`, which resolved against the
|
||||
// pre-redirect URL names a path on the old host — the home-set
|
||||
// PROPFIND 404s and a perfectly good account reports that it
|
||||
// holds no calendars.
|
||||
response[CalendarHomeSet::class.java]?.hrefs?.forEach { raw ->
|
||||
principalResource.location.resolve(raw)?.let(homeSets::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
homeSetResult.exceptionOrNull()?.let { error ->
|
||||
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host))
|
||||
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
|
||||
}
|
||||
|
||||
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
|
||||
// clear. The caller surfaces the host change to the user.
|
||||
val crossHost = homeSets.filter { it.host != principalResource.location.host }
|
||||
val insecure = homeSets.filter { principal.isHttps && !it.isHttps }
|
||||
if (insecure.isNotEmpty()) {
|
||||
return Outcome.Failed(
|
||||
Outcome.Cause.INSECURE,
|
||||
"calendar-home-set downgrades to HTTP: ${insecure.first()}",
|
||||
)
|
||||
}
|
||||
|
||||
val collections = linkedMapOf<HttpUrl, TaskCollection>()
|
||||
val failures = mutableListOf<HomeSetFailure>()
|
||||
var anySucceeded = false
|
||||
|
||||
for (homeSet in homeSets.distinct()) {
|
||||
val result = runCatching {
|
||||
DavResource(httpClient, homeSet).propfind(1, *collectionProperties) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) return@propfind
|
||||
CollectionClassifier.classify(response)?.let { collections[it.url] = it }
|
||||
}
|
||||
}
|
||||
result.fold(
|
||||
// A failed home set must not fail the account — the same rule the
|
||||
// engine runs on. One broken share must not hide every other list,
|
||||
// and a 401 from a cross-host home set must not tell the user to
|
||||
// sign in again with credentials that just worked.
|
||||
onSuccess = { anySucceeded = true },
|
||||
onFailure = {
|
||||
failures += HomeSetFailure(
|
||||
url = homeSet,
|
||||
reason = it.message ?: it.toString(),
|
||||
needsAuthentication = isUnauthorized(it),
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Every home set failing is a server problem, not a discovery that found
|
||||
// no lists. Reporting it as success gives the user "connected, no task
|
||||
// lists" for what is actually an error.
|
||||
if (!anySucceeded) {
|
||||
val needAuth = failures.filter { it.needsAuthentication }
|
||||
return if (needAuth.isNotEmpty() && needAuth.size == failures.size) {
|
||||
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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return Outcome.Found(
|
||||
principal = principal,
|
||||
collections = collections.values.toList(),
|
||||
homeSets = homeSets.distinct(),
|
||||
movedTo = movedTo,
|
||||
crossHostHomeSets = crossHost,
|
||||
failedHomeSets = failures,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isUnauthorized(error: Throwable): Boolean =
|
||||
error is at.bitfire.dav4jvm.exception.UnauthorizedException
|
||||
|
||||
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,176 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.asResponseBody
|
||||
import okio.GzipSource
|
||||
import okio.buffer
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The HTTP clients the CalDAV layer talks through.
|
||||
*
|
||||
* ⚠️ `followRedirects(false)` is mandatory, not a preference: `DavResource`
|
||||
* requires it and asserts on it. Redirects are followed by hand so a
|
||||
* HTTPS→HTTP downgrade can be refused and a permanent move can be reported to
|
||||
* the caller — see `dav/PROVENANCE.md` change 3.
|
||||
*/
|
||||
object CalDavHttp {
|
||||
|
||||
/**
|
||||
* One shared base client, so every derived client reuses its connection pool
|
||||
* and dispatcher threads. Building a fresh `OkHttpClient` per probe gives
|
||||
* each its own pool — every rung of the RFC 6764 ladder reopens TLS, and the
|
||||
* abandoned clients' idle threads live until GC.
|
||||
*/
|
||||
private val shared: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.followRedirects(false)
|
||||
// A homelab server on the end of a slow link is normal; a hung socket
|
||||
// is not. Bounded so a killed worker is the exception, not the rule.
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.writeTimeout(120, TimeUnit.SECONDS)
|
||||
// ⚠️ A whole-call ceiling, because the three above are per-attempt:
|
||||
// a server trickling one byte every 119 seconds satisfies the read
|
||||
// timeout for ever, and holds a sequential sync for the entire
|
||||
// WorkManager window while every later collection is skipped. Wide
|
||||
// enough for a large multiget on a slow homelab link, narrow enough
|
||||
// that one stalled request cannot eat the run.
|
||||
.callTimeout(3, TimeUnit.MINUTES)
|
||||
.build()
|
||||
}
|
||||
|
||||
/** Discovery before we have credentials, and the Nextcloud login flow. */
|
||||
fun anonymous(userAgent: String): OkHttpClient = base(userAgent).build()
|
||||
|
||||
/**
|
||||
* Authenticated against [origin]'s registrable domain.
|
||||
*
|
||||
* Uses the vendored [BasicDigestAuthHandler] rather than a hand-rolled
|
||||
* interceptor, and it is worth saying why, because a preemptive-Basic
|
||||
* interceptor is the obvious thing to write and this project wrote one first:
|
||||
*
|
||||
* - **It does Digest.** Baïkal defaults to `dav_auth_type = Digest` and OkHttp
|
||||
* has no Digest support of its own (square/okhttp#205, open for years).
|
||||
* Baïkal is squarely in the self-hosting audience.
|
||||
* - **It already sends Basic preemptively over HTTPS**, and only over HTTPS,
|
||||
* so the extra round trip on every request of a PROPFIND-heavy sync is
|
||||
* avoided without a second implementation.
|
||||
* - **It restricts by registrable domain**, which is what a cross-host home
|
||||
* set needs: iCloud puts the principal on `caldav.icloud.com` and the home
|
||||
* set on `pNN-caldav.icloud.com`, and an exact-host allowlist refuses the
|
||||
* second one.
|
||||
* - **It caches which scheme worked for the lifetime of the client**, so a
|
||||
* sync that reuses one client pays the 401 challenge once. ⚠️ The cache
|
||||
* lives on the handler, and this builds a fresh one per call — `discover`,
|
||||
* `revokeAppPassword` and `sync` each get their own — so a Digest-only
|
||||
* server pays one challenge per client, not one per process.
|
||||
*/
|
||||
fun authenticated(
|
||||
userAgent: String,
|
||||
username: String,
|
||||
password: String,
|
||||
origin: HttpUrl,
|
||||
): OkHttpClient {
|
||||
val handler = BasicDigestAuthHandler(
|
||||
// ⚠️ The **registrable** domain, not the host, and it must be
|
||||
// derived exactly as the handler derives it for each request — pass
|
||||
// "cloud.example.com" where the handler computes "example.com" and
|
||||
// the credential is withheld from every request. That is every
|
||||
// self-hosted Nextcloud.
|
||||
//
|
||||
// Public-suffix list, not a last-two-labels split: the split scopes
|
||||
// cloud.example.co.uk to co.uk and 192.168.1.10 to 1.10, offering
|
||||
// the password preemptively to strangers. `topPrivateDomain()` is
|
||||
// null for an IP literal or a single-label host, where the exact
|
||||
// host is the only safe scope.
|
||||
//
|
||||
// What this still trusts: two hosts under one registrable domain
|
||||
// have one owner. That is what a cross-host `calendar-home-set`
|
||||
// needs — iCloud answers on caldav.icloud.com and serves from
|
||||
// pNN-caldav.icloud.com — so a breached dav.example.com can still
|
||||
// claim evil.example.com. Narrowing further costs iCloud.
|
||||
domain = origin.topPrivateDomain() ?: origin.host,
|
||||
username = username,
|
||||
password = password,
|
||||
// Never over cleartext, challenged or not.
|
||||
insecureBasic = false,
|
||||
)
|
||||
return base(userAgent)
|
||||
.authenticator(handler)
|
||||
.addNetworkInterceptor(handler)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun base(userAgent: String) = shared.newBuilder()
|
||||
.addInterceptor(calendarHeaders)
|
||||
.addInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain.request().newBuilder().header("User-Agent", userAgent).build(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The two headers that decide whether ETags are usable and whether our bytes
|
||||
* survive the server.
|
||||
*
|
||||
* **`Accept-Encoding: identity`.** Setting it by hand disables OkHttp's
|
||||
* transparent gzip, and that is the point: ⚠️ a compressing intermediary
|
||||
* *weakens the ETag*. Any gzip-enabled nginx, Cloudflare or Traefik in front
|
||||
* of an otherwise perfect server turns every strong validator into `W/"…"`,
|
||||
* and a weak tag cannot be used with `If-Match` (RFC 9110 §13.1.1) — so
|
||||
* conditional writes stop working for reasons that live in the user's reverse
|
||||
* proxy rather than in their CalDAV server. Trading some bandwidth for a
|
||||
* working conditional PUT is the right side of that deal.
|
||||
*
|
||||
* **`Prefer: handling=strict` on writes.** sabre-based servers (Baïkal,
|
||||
* Nextcloud, ownCloud, SabreDAV itself) run vobject's `REPAIR` over anything
|
||||
* uploaded unless this is set, and `Server::createFile` then deliberately
|
||||
* withholds the ETag because the stored bytes are no longer ours. Strict
|
||||
* handling keeps both. RFC 7240 §2 requires an unrecognised preference to be
|
||||
* ignored, so sending it to every server costs nothing.
|
||||
*/
|
||||
private val calendarHeaders = Interceptor { chain ->
|
||||
val request = chain.request()
|
||||
val builder = request.newBuilder().header("Accept-Encoding", "identity")
|
||||
if (request.method in WRITE_METHODS) {
|
||||
// ⚠️ Appended, not replaced. A caller that set its own `Prefer` —
|
||||
// `return=minimal`, a `depth-noroot` — would otherwise have it
|
||||
// silently dropped on every write.
|
||||
val existing = request.header("Prefer")?.takeIf { it.isNotBlank() }
|
||||
builder.header("Prefer", listOfNotNull(existing, STRICT_HANDLING).joinToString(", "))
|
||||
}
|
||||
gunzip(chain.proceed(builder.build()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Undoes a compression we asked not to have.
|
||||
*
|
||||
* Setting `Accept-Encoding` by hand takes OkHttp's transparent gunzip out of
|
||||
* the loop — it only decompresses what it asked for itself. ⚠️ A proxy that
|
||||
* gzips regardless (a misconfigured nginx, an ISP middlebox) then hands raw
|
||||
* deflate bytes to the iCalendar parser, which reports it as an unreadable
|
||||
* body and quarantines a perfectly good resource. Honouring the header the
|
||||
* response actually carries costs nothing when nobody compressed anything.
|
||||
*/
|
||||
private fun gunzip(response: Response): Response {
|
||||
if (!"gzip".equals(response.header("Content-Encoding"), ignoreCase = true)) return response
|
||||
val body = response.body ?: return response
|
||||
val source = GzipSource(body.source()).buffer()
|
||||
return response.newBuilder()
|
||||
.removeHeader("Content-Encoding")
|
||||
// Dropped with it: the length describes the compressed bytes and is
|
||||
// a lie about what the caller now reads.
|
||||
.removeHeader("Content-Length")
|
||||
.body(source.asResponseBody(body.contentType(), contentLength = -1))
|
||||
.build()
|
||||
}
|
||||
|
||||
private val WRITE_METHODS = setOf("PUT", "POST", "PATCH")
|
||||
|
||||
private const val STRICT_HANDLING = "handling=strict"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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")),
|
||||
;
|
||||
|
||||
/**
|
||||
* The domain to put in an address hint, for a service the user reaches by
|
||||
* email address. Null for software they run themselves, whose host is theirs.
|
||||
*/
|
||||
val primaryDomain: String? get() = domains.firstOrNull()
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
* The services worth offering by name on a picker, in the order they
|
||||
* should be offered.
|
||||
*
|
||||
* Everything identified by domain, plus Nextcloud — picked for its
|
||||
* browser sign-in rather than for its host. The rest are self-hosted
|
||||
* software recognised *after* discovery, from the principal path, so
|
||||
* naming them here would only add rows that all ask the same question.
|
||||
*
|
||||
* A service that cannot be synced at all sorts to the **end**. It is
|
||||
* still listed — people come looking for Google, and an absent row reads
|
||||
* as the app being unfinished rather than as Google's own limitation —
|
||||
* but a dead row belongs under the live ones, not in the middle of them.
|
||||
* The sort is stable, so everything else keeps declaration order.
|
||||
*/
|
||||
val selectable: List<CalDavProvider>
|
||||
get() = entries.filter { it.domains.isNotEmpty() || it == NEXTCLOUD }
|
||||
.sortedBy { ServerQuirk.forProvider(it)?.isFatal == true }
|
||||
|
||||
/** 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/** What one `sync-collection` REPORT came back with. */
|
||||
sealed interface ChangeSet {
|
||||
|
||||
/**
|
||||
* One page of changes.
|
||||
*
|
||||
* A page, not a result: RFC 6578 lets a server truncate and hand back a token
|
||||
* that resumes where it stopped, so the caller applies this page's bodies,
|
||||
* stores [token], and only then asks for the next.
|
||||
*/
|
||||
data class Page(
|
||||
val changed: List<RemoteRef>,
|
||||
val removed: List<HttpUrl>,
|
||||
/**
|
||||
* The token to store **after** this page is applied.
|
||||
*
|
||||
* ⚠️ Null when the server sent a 207 with no `DAV:sync-token` at all,
|
||||
* which happens. That is not an error and not a reason to throw — the
|
||||
* changes in this page are still real. It means the next run has no
|
||||
* cursor and must reconcile in full.
|
||||
*/
|
||||
val token: String?,
|
||||
/**
|
||||
* The server stopped early and there is more behind [token].
|
||||
*
|
||||
* Signalled by a `507` on the collection's **own** href, which is
|
||||
* truncation rather than failure — a 507 as the *outer* response status
|
||||
* means something else entirely.
|
||||
*/
|
||||
val truncated: Boolean = false,
|
||||
) : ChangeSet
|
||||
|
||||
/**
|
||||
* The token is no longer usable and the collection must be reconciled in full.
|
||||
*
|
||||
* ⚠️ **Invalidation has no status code.** `403` (sabre), `400` (Google),
|
||||
* `409` (Radicale) and `412` (Evolution) are all observed in the wild, so the
|
||||
* only reliable signal is `DAV:valid-sync-token` in the body on **any** 4xx.
|
||||
* Thunderbird matches on 400 alone and therefore never recovers from the 403
|
||||
* that most of the self-hosted world emits.
|
||||
*/
|
||||
data object TokenInvalid : ChangeSet
|
||||
|
||||
/** The server will not do `sync-collection` here. Fall back, do not retry. */
|
||||
data object Unsupported : ChangeSet
|
||||
|
||||
data class Failed(val reason: String) : ChangeSet
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import at.bitfire.dav4jvm.DavCalendar
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.Error
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.CalendarData
|
||||
import at.bitfire.dav4jvm.property.GetCTag
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import at.bitfire.dav4jvm.property.SyncToken
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* One remote task collection, as a set of operations rather than a set of HTTP
|
||||
* calls. Every method here returns an outcome; none of them throw for anything a
|
||||
* server can legitimately answer.
|
||||
*
|
||||
* This class holds **no task-domain type and no Android type**, per the module
|
||||
* boundary — it deals in `String` bodies of `text/calendar`. Deciding what those
|
||||
* bodies mean is the engine's job, one module up.
|
||||
*/
|
||||
class CalendarCollection(
|
||||
client: OkHttpClient,
|
||||
override val url: HttpUrl,
|
||||
/** Our WebDAV-Push subscription here, named in `Push-Dont-Notify` on writes. */
|
||||
pushRegistration: HttpUrl? = null,
|
||||
) : RemoteCalendar {
|
||||
|
||||
private val httpClient: OkHttpClient = pushRegistration?.let { registration ->
|
||||
val header = WebDavPush.dontNotifyHeader(registration)
|
||||
client.newBuilder()
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
chain.proceed(
|
||||
if (request.method in NOTIFYING_METHODS) {
|
||||
request.newBuilder().header("Push-Dont-Notify", header).build()
|
||||
} else {
|
||||
request
|
||||
},
|
||||
)
|
||||
}
|
||||
.build()
|
||||
} ?: client
|
||||
|
||||
private val dav get() = DavCalendar(httpClient, url)
|
||||
|
||||
/** What a Depth-0 PROPFIND says about the collection right now. */
|
||||
data class State(
|
||||
val collection: TaskCollection,
|
||||
val ctag: String?,
|
||||
val syncToken: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Re-reads the collection's own properties.
|
||||
*
|
||||
* Worth doing on every sync rather than trusting what account-add recorded:
|
||||
* ⚠️ ACL churn is real — a calendar shared with the user can be revoked, or
|
||||
* demoted from read-write to read-only, with no notification of any kind. A
|
||||
* stale "writable" flag turns every upload into a 403 the user cannot act on,
|
||||
* and a stale "not shared" flag disarms the guard that keeps us from writing
|
||||
* a mutilated body back over the owner's task.
|
||||
*/
|
||||
override fun state(): Result<State> = runCatching {
|
||||
var found: State? = null
|
||||
var fromSelf = false
|
||||
DavResource(httpClient, url).propfind(
|
||||
0,
|
||||
*CollectionClassifier.PROPERTIES,
|
||||
GetCTag.NAME,
|
||||
SyncToken.NAME,
|
||||
) { response, relation ->
|
||||
val isSelf = relation == Response.HrefRelation.SELF
|
||||
// ⚠️ SELF wins outright and nothing overwrites it. A Depth-0 PROPFIND
|
||||
// that also reports a member — which some servers send — would
|
||||
// otherwise have that member classified as the collection's own
|
||||
// state. The non-SELF branch exists only because a server whose href
|
||||
// differs from ours by a trailing slash is classified OTHER, and
|
||||
// refusing those outright breaks it against real deployments.
|
||||
if (!isSelf && (fromSelf || found != null)) return@propfind
|
||||
|
||||
val collection = CollectionClassifier.classify(response) ?: return@propfind
|
||||
found = State(
|
||||
collection = collection,
|
||||
ctag = response[GetCTag::class.java]?.cTag,
|
||||
syncToken = response[SyncToken::class.java]?.token,
|
||||
)
|
||||
if (isSelf) fromSelf = true
|
||||
}
|
||||
found ?: throw IOException("$url is no longer a task collection")
|
||||
}
|
||||
|
||||
/**
|
||||
* Every VTODO resource in the collection, as href + ETag.
|
||||
*
|
||||
* ⚠️ **No time-range filter.** DAVx5 omits it for the same reason: some
|
||||
* servers return nothing at all for a `VTODO` comp-filter that carries one,
|
||||
* and a task without `DTSTART` or `DUE` is outside every range by
|
||||
* construction — which is most tasks.
|
||||
*
|
||||
* ⚠️ **No `calendar-data` in this REPORT.** A `calendar-query` that asks for
|
||||
* bodies downloads the whole collection on every listing; worse, the bodies
|
||||
* would arrive without a matched ETag from the same response. Bodies come
|
||||
* from [fetch] only.
|
||||
*/
|
||||
override fun list(): Result<List<RemoteRef>> = runCatching {
|
||||
val refs = mutableListOf<RemoteRef>()
|
||||
dav.calendarQuery(COMPONENT, null, null) { response, relation ->
|
||||
if (relation != Response.HrefRelation.MEMBER) return@calendarQuery
|
||||
if (!response.isSuccess()) return@calendarQuery
|
||||
refs += RemoteRef(response.href, ETag.from(response[GetETag::class.java]))
|
||||
}
|
||||
refs
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of changes since [token] (RFC 6578).
|
||||
*
|
||||
* ⚠️ **No `DAV:limit`.** Nextcloud regressed it to a localised HTML error
|
||||
* page, so asking for a bounded page is how you get an unparseable response
|
||||
* instead of a bounded one. Truncation is the server's decision, signalled
|
||||
* back through [ChangeSet.Page.truncated].
|
||||
*
|
||||
* ⚠️ **A removal is a `404` at `DAV:response` level**, not inside a
|
||||
* `propstat`. A client that only reads statuses out of propstats sees every
|
||||
* deletion as an unremarkable response with no properties and silently keeps
|
||||
* the row.
|
||||
*/
|
||||
override fun changes(token: String?): ChangeSet = try {
|
||||
val changed = mutableListOf<RemoteRef>()
|
||||
val removed = mutableListOf<HttpUrl>()
|
||||
var truncated = false
|
||||
|
||||
val properties = DavCollection(httpClient, url).reportChanges(
|
||||
syncToken = token,
|
||||
infiniteDepth = false,
|
||||
limit = null,
|
||||
GetETag.NAME,
|
||||
) { response, relation ->
|
||||
val code = response.status?.code
|
||||
when {
|
||||
relation == Response.HrefRelation.SELF ->
|
||||
// On our own href this is truncation, not a failure.
|
||||
if (code == INSUFFICIENT_STORAGE) truncated = true
|
||||
|
||||
code == NOT_FOUND || code == GONE -> removed += response.href
|
||||
|
||||
response.isSuccess() ->
|
||||
changed += RemoteRef(response.href, ETag.from(response[GetETag::class.java]))
|
||||
|
||||
// ⚠️ Neither a success nor a removal — a per-object ACL
|
||||
// answering 403, a server failing on its own object with 5xx.
|
||||
// Dropping it reports the resource as *unchanged*, so the row
|
||||
// keeps whatever it holds until the next full reconciliation
|
||||
// notices the ETag differs. Carried as changed with no
|
||||
// validator instead: the multiget then produces a real verdict
|
||||
// for it, and `fetch` already grades one — the same reasoning
|
||||
// error, and the same fix, as its twin next door.
|
||||
else -> changed += RemoteRef(response.href, eTag = null)
|
||||
}
|
||||
}
|
||||
|
||||
ChangeSet.Page(
|
||||
changed = changed,
|
||||
removed = removed,
|
||||
token = properties.filterIsInstance<SyncToken>().firstOrNull()?.token,
|
||||
truncated = truncated,
|
||||
)
|
||||
} catch (e: HttpException) {
|
||||
when {
|
||||
e.code / 100 == 4 && mentionsInvalidToken(e) -> ChangeSet.TokenInvalid
|
||||
e.code in UNSUPPORTED -> ChangeSet.Unsupported
|
||||
else -> ChangeSet.Failed("HTTP ${e.code}")
|
||||
}
|
||||
} catch (e: DavException) {
|
||||
ChangeSet.Failed(e.toString())
|
||||
} catch (e: IOException) {
|
||||
ChangeSet.Failed(e.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ The body, not the status. Every observed invalidation status is also a
|
||||
* legitimate answer to something else, and every server picks a different
|
||||
* one — so the element is the signal and the code is noise.
|
||||
*/
|
||||
private fun mentionsInvalidToken(e: HttpException): Boolean =
|
||||
Error.VALID_SYNC_TOKEN in e.errors ||
|
||||
e.responseBody?.contains(VALID_SYNC_TOKEN_ELEMENT) == true
|
||||
|
||||
/**
|
||||
* Downloads [hrefs] with `calendar-multiget`, in batches.
|
||||
*
|
||||
* ⚠️ Responses are **matched against the request**. Real servers reply with
|
||||
* responses for URLs that were never asked for — a collection's own href, a
|
||||
* sibling, occasionally something from another collection entirely — and a
|
||||
* client that indexes the response by position or trusts it wholesale will
|
||||
* apply one resource's body to another's row.
|
||||
*/
|
||||
override fun fetch(hrefs: List<HttpUrl>): Result<FetchResult> = fetch(hrefs, MULTIGET_BATCH)
|
||||
|
||||
/**
|
||||
* What makes two hrefs the same resource.
|
||||
*
|
||||
* ⚠️ Not the raw `encodedPath`. The request href is *ours*, written into the
|
||||
* REPORT body verbatim, and the response href is the server's own spelling
|
||||
* of it — a server that answers `/my@dav.ics` for a requested `/my%40dav.ics`
|
||||
* is the reason `UrlUtils.equals` exists at all. Compared raw, that resource
|
||||
* lands in `unsolicited` *and* in `missing`, its intact body is dropped, and
|
||||
* three such runs quarantine it out of the download for good, since nothing
|
||||
* on this side can refund the count.
|
||||
*
|
||||
* `pathSegments` is decoded, so both spellings agree. Keeping it a list, not
|
||||
* a joined string, stops `/a%2Fb` colliding with `/a/b`. The trailing slash
|
||||
* still distinguishes, exactly as `UrlUtils.equals` refuses to normalise it.
|
||||
*
|
||||
* ⚠️ The origin is deliberately *not* part of this key, and is checked
|
||||
* separately by [answersFor]. The two sides do not share one by
|
||||
* construction: the REPORT body carries only our paths, so the reply's href
|
||||
* resolves against the collection's *post-redirect* location, while our
|
||||
* stored hrefs still carry the origin we had before the redirect. Keying on
|
||||
* the origin would put every resource of a redirected collection into both
|
||||
* `unsolicited` and `missing`.
|
||||
*/
|
||||
private fun identityOf(url: HttpUrl): List<String> = url.pathSegments
|
||||
|
||||
/**
|
||||
* Whether a reply about [answered] can be the resource we asked for at
|
||||
* [asked].
|
||||
*
|
||||
* Same host as the href we asked for, or as the collection we asked it of —
|
||||
* the latter covering a redirect we followed. Anything else is a body from
|
||||
* somewhere we never spoke to, and applying it would write one host's
|
||||
* content into another host's row.
|
||||
*/
|
||||
private fun answersFor(answered: HttpUrl, asked: HttpUrl, collection: HttpUrl): Boolean =
|
||||
answered.host.equals(asked.host, ignoreCase = true) ||
|
||||
answered.host.equals(collection.host, ignoreCase = true)
|
||||
|
||||
/** [batchSize] is a seam for tests; production always uses [MULTIGET_BATCH]. */
|
||||
internal fun fetch(hrefs: List<HttpUrl>, batchSize: Int): Result<FetchResult> {
|
||||
val resources = mutableListOf<RemoteResource>()
|
||||
val unsolicited = mutableListOf<HttpUrl>()
|
||||
val failed = mutableListOf<FetchFailure>()
|
||||
val seen = mutableSetOf<HttpUrl>()
|
||||
|
||||
// ⚠️ Per batch, not around the whole loop. A 500 on batch seven of ten
|
||||
// used to discard the two hundred resources already parsed and answer
|
||||
// `Result.failure`, so the caller recorded a collection failure and
|
||||
// re-downloaded everything next run — on a flaky link, for ever. A batch
|
||||
// that could not be asked is exactly what `missing` means, and the
|
||||
// per-resource verdicts the engine already applies say the rest.
|
||||
val batches = hrefs.chunked(batchSize)
|
||||
for (batch in batches) {
|
||||
val attempt = runCatching {
|
||||
val byExactPath = batch.associateBy { it.encodedPath }
|
||||
// ⚠️ Only identities that name exactly one request href. Two
|
||||
// spellings of one path — `/my%40dav.ics` and `/my@dav.ics` —
|
||||
// decode alike but are separate rows here, and silently keeping
|
||||
// the last would report the other as missing and eventually
|
||||
// quarantine it. Where we cannot tell them apart, the exact
|
||||
// spelling is the only honest match.
|
||||
val byPath = batch.groupBy { identityOf(it) }
|
||||
.filterValues { it.size == 1 }
|
||||
.mapValues { (_, matches) -> matches.single() }
|
||||
val calendar = dav
|
||||
calendar.multiget(batch, MIME_ICALENDAR, ICALENDAR_VERSION) { response, relation ->
|
||||
if (relation == Response.HrefRelation.SELF) return@multiget
|
||||
val candidate = byExactPath[response.href.encodedPath]
|
||||
?: byPath[identityOf(response.href)]
|
||||
val asked = candidate
|
||||
?.takeIf { answersFor(response.href, it, calendar.location) }
|
||||
if (asked == null) {
|
||||
unsolicited += response.href
|
||||
return@multiget
|
||||
}
|
||||
// Before the checks below, and deliberately: the server did
|
||||
// mention this href, so whatever it said, the href is not
|
||||
// one the server omitted.
|
||||
seen += asked
|
||||
if (!response.isSuccess()) {
|
||||
failed += FetchFailure(asked, response.status?.code ?: 0)
|
||||
return@multiget
|
||||
}
|
||||
val body = response[CalendarData::class.java]?.iCalendar
|
||||
if (body == null) {
|
||||
// Success at the response level, no body. The usual shape
|
||||
// is a per-property refusal — a `propstat` carrying 403
|
||||
// around `calendar-data` — and `Response.properties` drops
|
||||
// non-2xx propstats silently, so the verdict has to be
|
||||
// read back out of them or a deterministic refusal
|
||||
// arrives looking like a transient blank.
|
||||
val refusal = response.propstat.firstOrNull { !it.isSuccess() }
|
||||
failed += FetchFailure(asked, refusal?.status?.code ?: 0)
|
||||
return@multiget
|
||||
}
|
||||
resources += RemoteResource(
|
||||
href = asked,
|
||||
// The tag from *this* response, paired with *this* body.
|
||||
eTag = ETag.from(response[GetETag::class.java]),
|
||||
iCalendar = body,
|
||||
)
|
||||
}
|
||||
}
|
||||
// ⚠️ The first batch to fail ends the fetch, but keeps what came
|
||||
// before it. Carrying on would re-ask a server that has just said it
|
||||
// cannot answer, and the hrefs left unasked fall out as `missing` —
|
||||
// which the engine grades as "listed but not returned" and counts,
|
||||
// rather than acting on as a deletion. A whole run that fails on the
|
||||
// first batch still answers `failure`, exactly as before.
|
||||
if (attempt.isFailure) {
|
||||
if (resources.isEmpty() && failed.isEmpty()) {
|
||||
return Result.failure(attempt.exceptionOrNull()!!)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return Result.success(
|
||||
FetchResult(
|
||||
resources = resources,
|
||||
missing = hrefs.filterNot { it in seen },
|
||||
// A server that repeats a response element must not spend two
|
||||
// thirds of the quarantine threshold in one run — nor count
|
||||
// against a resource it also answered properly.
|
||||
failed = failed
|
||||
.filterNot { failure -> resources.any { it.href == failure.href } }
|
||||
.distinctBy { it.href },
|
||||
unsolicited = unsolicited,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a resource at [name] with `If-None-Match: *`.
|
||||
*
|
||||
* The conditional is what makes creation safe to retry: without it a
|
||||
* re-running worker overwrites whatever a previous run put there.
|
||||
*/
|
||||
override fun create(name: String, iCalendar: String): PutOutcome {
|
||||
val href = url.newBuilder().addPathSegment(name).build()
|
||||
return put(href, iCalendar, ifETag = null, ifNoneMatch = true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces [href], conditional on [eTag] when one is given.
|
||||
*
|
||||
* A null [eTag] means the server has never handed us a strong validator for
|
||||
* this resource, and a conditional write is therefore impossible rather than
|
||||
* merely inconvenient. The engine decides whether writing anyway is
|
||||
* acceptable; this class only carries out the decision.
|
||||
*/
|
||||
override fun update(href: HttpUrl, eTag: String?, iCalendar: String): PutOutcome =
|
||||
put(href, iCalendar, ifETag = eTag, ifNoneMatch = false)
|
||||
|
||||
private fun put(
|
||||
href: HttpUrl,
|
||||
iCalendar: String,
|
||||
ifETag: String?,
|
||||
ifNoneMatch: Boolean,
|
||||
): PutOutcome = try {
|
||||
var outcome: PutOutcome = PutOutcome.StoredNeedsRefetch(href)
|
||||
DavResource(httpClient, href).put(
|
||||
body = iCalendar.toRequestBody(DavCalendar.MIME_ICALENDAR_UTF8),
|
||||
ifETag = ifETag,
|
||||
ifNoneMatch = ifNoneMatch,
|
||||
) { response ->
|
||||
// The server may have stored the resource somewhere else entirely.
|
||||
val location = response.header("Location")
|
||||
?.let { href.resolve(it) }
|
||||
?: href
|
||||
val eTag = ETag.parse(response.header("ETag"))
|
||||
outcome = if (eTag != null && eTag.usable) {
|
||||
PutOutcome.Stored(location, eTag)
|
||||
} else {
|
||||
PutOutcome.StoredNeedsRefetch(location)
|
||||
}
|
||||
}
|
||||
outcome
|
||||
} catch (e: HttpException) {
|
||||
classifyPutFailure(href, e, ifNoneMatch)
|
||||
} catch (e: DavException) {
|
||||
// Not an IOException: a refused HTTPS->HTTP redirect arrives here, and
|
||||
// it is a configuration problem rather than a transient one.
|
||||
PutOutcome.Rejected(0, e.toString())
|
||||
} catch (e: IOException) {
|
||||
PutOutcome.Failed(e.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **412 means three different things** and merging any two of them is a
|
||||
* data-loss bug.
|
||||
*/
|
||||
private fun classifyPutFailure(
|
||||
href: HttpUrl,
|
||||
e: HttpException,
|
||||
wasCreate: Boolean,
|
||||
): PutOutcome = when {
|
||||
e.code != PRECONDITION_FAILED -> PutOutcome.Rejected(e.code, e.message.orEmpty())
|
||||
|
||||
// On create the precondition was `If-None-Match: *`, so 412 says only
|
||||
// that the name is occupied. It says nothing about by what — the engine
|
||||
// re-fetches and adopts the resource if the UID matches.
|
||||
wasCreate -> PutOutcome.NameTaken(href)
|
||||
|
||||
// On update the precondition was `If-Match`, and a resource that is gone
|
||||
// fails it just as one that changed does. RFC 9110 §13.1.1 requires 412
|
||||
// rather than 404 once the precondition is evaluated, so the two are
|
||||
// indistinguishable without asking again.
|
||||
!exists(href) -> PutOutcome.Vanished
|
||||
|
||||
else -> PutOutcome.ServerNewer
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes [href], conditional on [eTag] when we hold a usable one.
|
||||
*
|
||||
* ⚠️ 404 and 410 are **success**. The purpose of a DELETE is for the
|
||||
* resource to be absent, and it is; treating "already gone" as a failure
|
||||
* leaves a tombstone that is retried on every sync forever.
|
||||
*/
|
||||
override fun delete(href: HttpUrl, eTag: String?): DeleteOutcome = try {
|
||||
DavResource(httpClient, href).delete(ifETag = eTag) { }
|
||||
DeleteOutcome.Deleted
|
||||
} catch (e: HttpException) {
|
||||
when (e.code) {
|
||||
NOT_FOUND, GONE -> DeleteOutcome.Deleted
|
||||
PRECONDITION_FAILED -> if (exists(href)) {
|
||||
DeleteOutcome.ServerNewer
|
||||
} else {
|
||||
DeleteOutcome.Deleted
|
||||
}
|
||||
else -> DeleteOutcome.Rejected(e.code, e.message.orEmpty())
|
||||
}
|
||||
} catch (e: DavException) {
|
||||
DeleteOutcome.Rejected(0, e.toString())
|
||||
} catch (e: IOException) {
|
||||
DeleteOutcome.Failed(e.toString())
|
||||
}
|
||||
|
||||
/** A HEAD, used only to tell "changed" from "gone" apart after a 412. */
|
||||
private fun exists(href: HttpUrl): Boolean = try {
|
||||
DavResource(httpClient, href).head { }
|
||||
true
|
||||
} catch (e: HttpException) {
|
||||
e.code != NOT_FOUND && e.code != GONE
|
||||
} catch (_: DavException) {
|
||||
true
|
||||
} catch (_: IOException) {
|
||||
// Unknown. Treat as still present: discarding a local edit is
|
||||
// irreversible, and giving up on this resource until the next run is not.
|
||||
true
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val COMPONENT = "VTODO"
|
||||
|
||||
/**
|
||||
* Resources per `calendar-multiget`.
|
||||
*
|
||||
* A whole collection in one REPORT is a request body and a response that
|
||||
* a modest server will refuse outright, and a failure mid-way costs the
|
||||
* entire batch. DAVx5 settled on the same order of magnitude.
|
||||
*/
|
||||
const val MULTIGET_BATCH = 30
|
||||
|
||||
private const val MIME_ICALENDAR = "text/calendar"
|
||||
private const val ICALENDAR_VERSION = "2.0"
|
||||
|
||||
private val NOTIFYING_METHODS = setOf("PUT", "DELETE")
|
||||
|
||||
private const val NOT_FOUND = 404
|
||||
private const val GONE = 410
|
||||
private const val PRECONDITION_FAILED = 412
|
||||
private const val INSUFFICIENT_STORAGE = 507
|
||||
|
||||
/** Matched as a local name, so a server's own namespace prefix is irrelevant. */
|
||||
private const val VALID_SYNC_TOKEN_ELEMENT = "valid-sync-token"
|
||||
|
||||
/**
|
||||
* The server does not implement the report. Deliberately narrow: 400,
|
||||
* 403, 409 and 412 are all *invalidation* codes on some server, so
|
||||
* treating them as "unsupported" would abandon `sync-collection` on a
|
||||
* collection that merely needs a fresh token.
|
||||
*/
|
||||
private val UNSUPPORTED = setOf(405, 415, 501)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import at.bitfire.dav4jvm.property.GetETag
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* An entity tag, plus the one bit of it that changes behaviour.
|
||||
*
|
||||
* ⚠️ A weak tag is not a slightly worse strong tag — it is unusable for what we
|
||||
* need one for. RFC 9110 §13.1.1: *"A weak entity-tag cannot be used with
|
||||
* If-Match."* So a weak tag must never be persisted as a validator; the resource
|
||||
* is re-fetched instead.
|
||||
*
|
||||
* Weak tags are not exotic. On sabre they are the **default** path unless
|
||||
* `Prefer: handling=strict` is sent, and any gzip-compressing nginx, Cloudflare
|
||||
* or Traefik in front of the server weakens them regardless of the server
|
||||
* software — which is why the calendar client asks for `Accept-Encoding:
|
||||
* identity`.
|
||||
*/
|
||||
data class ETag(val value: String, val weak: Boolean) {
|
||||
|
||||
/** Whether this tag may be persisted and later sent as `If-Match`. */
|
||||
val usable: Boolean get() = !weak && value.isNotBlank()
|
||||
|
||||
override fun toString() = if (weak) "W/\"$value\"" else "\"$value\""
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The tag from a parsed `DAV:getetag` property.
|
||||
*
|
||||
* ⚠️ Not [parse]. `GetETag` has *already* stripped the `W/` marker and
|
||||
* recorded it separately, so feeding its [GetETag.eTag] back through a
|
||||
* parser reports every weak tag as strong — which is precisely the
|
||||
* failure the weak flag exists to prevent.
|
||||
*/
|
||||
fun from(property: GetETag?): ETag? {
|
||||
val value = property?.eTag?.takeIf { it.isNotBlank() } ?: return null
|
||||
return ETag(value, property.weak == true)
|
||||
}
|
||||
|
||||
/** Parses a raw `ETag` **header**; null when absent. */
|
||||
fun parse(raw: String?): ETag? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val parsed = GetETag(trimmed)
|
||||
val value = parsed.eTag?.takeIf { it.isNotBlank() } ?: return null
|
||||
return ETag(value, parsed.weak == true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A resource as a listing reports it: an href and an ETag, with no body. */
|
||||
data class RemoteRef(val href: HttpUrl, val eTag: ETag?)
|
||||
|
||||
/**
|
||||
* A resource together with the body it was served with.
|
||||
*
|
||||
* ⚠️ [eTag] is the tag that arrived **in the same response as [iCalendar]**, and
|
||||
* only such a pairing is ever stored. A tag taken from a listing and a body
|
||||
* taken from a later fetch are not a matched pair, and treating them as one is
|
||||
* how the Nextcloud shared-calendar landmine detonates: `CalendarObject::get()`
|
||||
* hands back a body it has stripped while leaving the ETag alone, so an
|
||||
* unmatched pair lets us `If-Match` a mutilated body over the real one.
|
||||
*/
|
||||
data class RemoteResource(val href: HttpUrl, val eTag: ETag?, val iCalendar: String)
|
||||
|
||||
/**
|
||||
* A resource the server answered for, but did not hand over.
|
||||
*
|
||||
* @param code the response-level status, or `0` when the server reported
|
||||
* success and supplied no `calendar-data` — the same "no HTTP judgement to
|
||||
* report" convention [PutOutcome.Rejected] uses.
|
||||
*/
|
||||
data class FetchFailure(val href: HttpUrl, val code: Int)
|
||||
|
||||
/** What a multiget actually returned, and what it did not. */
|
||||
data class FetchResult(
|
||||
val resources: List<RemoteResource>,
|
||||
/** Asked for, not answered — the server simply omitted them. */
|
||||
val missing: List<HttpUrl>,
|
||||
/**
|
||||
* Asked for, answered, and refused or empty.
|
||||
*
|
||||
* Separate from [missing] because "the server said 403" and "the server said
|
||||
* nothing" are different facts with different right answers — merging them
|
||||
* is the same mistake as merging the three meanings of a 412.
|
||||
*/
|
||||
val failed: List<FetchFailure>,
|
||||
/**
|
||||
* Answered without being asked for.
|
||||
*
|
||||
* Real servers do this, so responses are matched against the request rather
|
||||
* than trusted, and the strays are counted rather than silently applied.
|
||||
*/
|
||||
val unsolicited: List<HttpUrl>,
|
||||
)
|
||||
|
||||
/** The outcome of a PUT. Every branch here is a different bug if merged with another. */
|
||||
sealed interface PutOutcome {
|
||||
|
||||
/** Stored, with a strong ETag we can use as the next `If-Match`. */
|
||||
data class Stored(val href: HttpUrl, val eTag: ETag) : PutOutcome
|
||||
|
||||
/**
|
||||
* Stored, but the ETag was absent or weak.
|
||||
*
|
||||
* Not an error and not a conflict: the resource is on the server. It has to
|
||||
* be re-fetched for a usable validator *and* for the server's canonical
|
||||
* body, which may not be the bytes we sent.
|
||||
*/
|
||||
data class StoredNeedsRefetch(val href: HttpUrl) : PutOutcome
|
||||
|
||||
/** 412 against `If-None-Match: *` — something already lives at this name. */
|
||||
data class NameTaken(val href: HttpUrl) : PutOutcome
|
||||
|
||||
/** 412 against `If-Match`, and the resource is still there: the server is newer. */
|
||||
data object ServerNewer : PutOutcome
|
||||
|
||||
/**
|
||||
* 412 against `If-Match`, and the resource is gone.
|
||||
*
|
||||
* Spec-correct and not a conflict at all — a delete on the server racing an
|
||||
* edit here. RFC 9110 requires 412 rather than 404 when the precondition is
|
||||
* evaluated, so only a follow-up `HEAD` can tell the two apart.
|
||||
*/
|
||||
data object Vanished : PutOutcome
|
||||
|
||||
/**
|
||||
* The server refused this body, and sending it again will not help.
|
||||
*
|
||||
* Covers 4xx and 5xx alike: `507` MUST NOT be auto-retried (RFC 4918 §11.5)
|
||||
* and a contradictory `RRULE`/`EXDATE` pair returns 500 from Nextcloud
|
||||
* forever, so neither class earns a retry loop.
|
||||
*/
|
||||
data class Rejected(val code: Int, val message: String) : PutOutcome
|
||||
|
||||
/** The request never got an answer. Transport, not judgement. */
|
||||
data class Failed(val reason: String) : PutOutcome
|
||||
}
|
||||
|
||||
/** The outcome of a DELETE. */
|
||||
sealed interface DeleteOutcome {
|
||||
|
||||
/**
|
||||
* Gone from the server.
|
||||
*
|
||||
* 404 and 410 count as success: the goal was for the resource not to exist,
|
||||
* and it does not.
|
||||
*/
|
||||
data object Deleted : DeleteOutcome
|
||||
|
||||
/** 412: the server's copy changed after we last saw it. */
|
||||
data object ServerNewer : DeleteOutcome
|
||||
|
||||
data class Rejected(val code: Int, val message: String) : DeleteOutcome
|
||||
|
||||
data class Failed(val reason: String) : DeleteOutcome
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.io.StringWriter
|
||||
|
||||
/**
|
||||
* What a server will let us do to the collections themselves.
|
||||
*
|
||||
* ⚠️ Asked, never assumed, because the alternative is an affordance that
|
||||
* 405s: **iCloud is
|
||||
* extended-MKCOL only** and answers 405 to MKCALENDAR, **Google has neither**
|
||||
* (and no VTODO either), and **Posteo disables collection creation outright**.
|
||||
* Offering "new list on this account" to any of them produces a failure the user
|
||||
* can do nothing about, at the end of a form they have already filled in.
|
||||
*/
|
||||
data class CollectionSupport(
|
||||
/** RFC 4791 §5.3.1. The straightforward route, and what Nextcloud wants. */
|
||||
val mkCalendar: Boolean,
|
||||
/** RFC 5689: MKCOL carrying a body that sets `resourcetype`. iCloud's route. */
|
||||
val extendedMkCol: Boolean,
|
||||
) {
|
||||
/** Whether a "new list here" affordance should exist at all. */
|
||||
val canCreate: Boolean get() = mkCalendar || extendedMkCol
|
||||
|
||||
companion object {
|
||||
/** What an OPTIONS we could not read has to mean: offer nothing. */
|
||||
val NONE = CollectionSupport(mkCalendar = false, extendedMkCol = false)
|
||||
}
|
||||
}
|
||||
|
||||
/** How a collection write ended. */
|
||||
sealed interface CollectionOutcome {
|
||||
data class Created(val url: HttpUrl) : CollectionOutcome
|
||||
data object Updated : CollectionOutcome
|
||||
|
||||
/**
|
||||
* The server understood and refused. Terminal: retrying writes nothing and
|
||||
* the user has to be told.
|
||||
*
|
||||
* [code] is for logs and for deciding; the caller owns the wording, the same
|
||||
* rule [CalDavDiscovery.Outcome.Cause] states.
|
||||
*/
|
||||
data class Refused(val code: Int, val reason: String) : CollectionOutcome
|
||||
|
||||
/** The server never answered. Worth retrying, unlike [Refused]. */
|
||||
data class Failed(val reason: String) : CollectionOutcome
|
||||
|
||||
/** Neither creation method exists here. Nothing to retry and nothing to fix. */
|
||||
data object Unsupported : CollectionOutcome
|
||||
}
|
||||
|
||||
/**
|
||||
* Creating, renaming, recolouring and deleting task collections.
|
||||
*
|
||||
* A seam, like [RemoteCalendar]: deciding *what* to do to a collection — refuse
|
||||
* a read-only one, pick a name that will not collide, roll a local row back when
|
||||
* the server says no — is the app's business and should not need a server to
|
||||
* exercise. [DavCollectionAdmin] is the only production implementation.
|
||||
*/
|
||||
interface CollectionAdmin {
|
||||
|
||||
/** What [homeSet] supports, as an OPTIONS rather than a guess. */
|
||||
fun support(homeSet: HttpUrl): CollectionSupport
|
||||
|
||||
/**
|
||||
* Makes a task collection under [homeSet].
|
||||
*
|
||||
* @param name the path segment to create it at. The caller owns collision
|
||||
* handling, because only it knows whether a second attempt is wanted.
|
||||
*/
|
||||
fun create(
|
||||
homeSet: HttpUrl,
|
||||
name: String,
|
||||
displayName: String,
|
||||
color: Int?,
|
||||
support: CollectionSupport,
|
||||
): CollectionOutcome
|
||||
|
||||
/** PROPPATCH: a rename, a recolour, or both. */
|
||||
fun updateProperties(url: HttpUrl, displayName: String?, color: Int?): CollectionOutcome
|
||||
|
||||
/** DELETE. The tasks go with it, on the server and everywhere else. */
|
||||
fun delete(url: HttpUrl): CollectionOutcome
|
||||
}
|
||||
|
||||
class DavCollectionAdmin(
|
||||
private val httpClient: OkHttpClient,
|
||||
) : CollectionAdmin {
|
||||
|
||||
/**
|
||||
* ⚠️ Both headers, and neither on its own is enough.
|
||||
*
|
||||
* `Allow` names the methods, which is where MKCALENDAR shows up; `DAV` names
|
||||
* the compliance classes, which is the only place `extended-mkcol` is ever
|
||||
* advertised (RFC 5689 §5.1). A server may support extended MKCOL while
|
||||
* listing plain MKCOL in `Allow` — that is the normal shape — so reading
|
||||
* `Allow` alone reports iCloud as unable to create anything.
|
||||
*/
|
||||
override fun support(homeSet: HttpUrl): CollectionSupport = try {
|
||||
// No compression: some servers have broken compression for OPTIONS,
|
||||
// which is why DavResource disables it for the same request.
|
||||
val request = Request.Builder()
|
||||
.url(homeSet)
|
||||
.method("OPTIONS", null)
|
||||
.header("Content-Length", "0")
|
||||
.header("Accept-Encoding", "identity")
|
||||
.build()
|
||||
Redirects.follow(httpClient, request).use { response ->
|
||||
if (!response.isSuccessful) return CollectionSupport.NONE
|
||||
val allow = headerTokens(response.headers("Allow"))
|
||||
val dav = headerTokens(response.headers("DAV"))
|
||||
CollectionSupport(
|
||||
mkCalendar = "MKCALENDAR" in allow,
|
||||
extendedMkCol = "EXTENDED-MKCOL" in dav,
|
||||
)
|
||||
}
|
||||
} catch (_: IOException) {
|
||||
// Unknown reads as "no", which costs the user an affordance they can
|
||||
// reach again on the next attempt — the other way round costs them a
|
||||
// 405 at the end of a form.
|
||||
CollectionSupport.NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ MKCALENDAR first where both exist. It is the method written for this
|
||||
* job, every server that has it treats it as authoritative, and extended
|
||||
* MKCOL's `resourcetype` set is refused outright by some servers that
|
||||
* nonetheless advertise the class.
|
||||
*/
|
||||
override fun create(
|
||||
homeSet: HttpUrl,
|
||||
name: String,
|
||||
displayName: String,
|
||||
color: Int?,
|
||||
support: CollectionSupport,
|
||||
): CollectionOutcome {
|
||||
if (!support.canCreate) return CollectionOutcome.Unsupported
|
||||
// A trailing slash, always: a collection URL that lacks one is resolved
|
||||
// against its *parent* by every later `resolve`, so the first resource
|
||||
// written into it lands one level up.
|
||||
val url = homeSet.newBuilder().addPathSegment(name).addPathSegment("").build()
|
||||
val (method, body) = if (support.mkCalendar) {
|
||||
"MKCALENDAR" to mkCalendarBody(displayName, color)
|
||||
} else {
|
||||
"MKCOL" to extendedMkColBody(displayName, color)
|
||||
}
|
||||
return send(method, url, body) { CollectionOutcome.Created(url) }
|
||||
}
|
||||
|
||||
override fun updateProperties(
|
||||
url: HttpUrl,
|
||||
displayName: String?,
|
||||
color: Int?,
|
||||
): CollectionOutcome {
|
||||
if (displayName == null && color == null) return CollectionOutcome.Updated
|
||||
return send("PROPPATCH", url, propPatchBody(displayName, color)) {
|
||||
// ⚠️ The 207 is not read for per-property statuses, deliberately. A
|
||||
// server that stores the name and refuses the colour answers 207
|
||||
// with a 403 propstat around `calendar-color` — and there is nothing
|
||||
// for the user to do about a colour their server will not keep,
|
||||
// while failing the whole rename over it would be worse. The colour
|
||||
// is ours locally either way.
|
||||
CollectionOutcome.Updated
|
||||
}
|
||||
}
|
||||
|
||||
override fun delete(url: HttpUrl): CollectionOutcome {
|
||||
val request = Request.Builder().url(url).delete().build()
|
||||
return execute(request) { response ->
|
||||
// ⚠️ 404 and 410 are success, exactly as they are for a resource:
|
||||
// the point of a DELETE is for the thing to be absent, and it is.
|
||||
when {
|
||||
response.isSuccessful || response.code == NOT_FOUND || response.code == GONE ->
|
||||
CollectionOutcome.Updated
|
||||
else -> CollectionOutcome.Refused(response.code, response.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun send(
|
||||
method: String,
|
||||
url: HttpUrl,
|
||||
body: String,
|
||||
onSuccess: () -> CollectionOutcome,
|
||||
): CollectionOutcome {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.method(method, body.toRequestBody(MIME_XML))
|
||||
.build()
|
||||
return execute(request) { response ->
|
||||
if (response.isSuccessful) onSuccess() else {
|
||||
CollectionOutcome.Refused(response.code, response.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun execute(
|
||||
request: Request,
|
||||
grade: (okhttp3.Response) -> CollectionOutcome,
|
||||
): CollectionOutcome = try {
|
||||
Redirects.follow(httpClient, request).use(grade)
|
||||
} catch (e: IOException) {
|
||||
CollectionOutcome.Failed(e.message ?: e.toString())
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ `VTODO` and nothing else.
|
||||
*
|
||||
* The component set is the one property here that changes what the
|
||||
* collection *is*: a calendar created without it is a
|
||||
* `supported-calendar-component-set` of the server's choosing, which on
|
||||
* several is events-only — and a task list that refuses tasks is the worst
|
||||
* possible outcome of "new list".
|
||||
*/
|
||||
private fun mkCalendarBody(displayName: String, color: Int?): String = xml { serializer ->
|
||||
serializer.insertTag(MKCALENDAR) {
|
||||
insertTag(SET) {
|
||||
insertTag(PROP) {
|
||||
insertTag(DISPLAYNAME) { text(displayName) }
|
||||
insertTag(COMPONENT_SET) {
|
||||
insertTag(COMP) { attribute(null, "name", COMPONENT) }
|
||||
}
|
||||
color?.let { insertTag(CALENDAR_COLOR) { text(hexOf(it)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 5689, where the resource type is set by the request rather than by the
|
||||
* method — which is the whole difference, and the reason iCloud needs this
|
||||
* one.
|
||||
*/
|
||||
private fun extendedMkColBody(displayName: String, color: Int?): String = xml { serializer ->
|
||||
serializer.insertTag(MKCOL) {
|
||||
insertTag(SET) {
|
||||
insertTag(PROP) {
|
||||
insertTag(RESOURCETYPE) {
|
||||
insertTag(COLLECTION)
|
||||
insertTag(CALENDAR)
|
||||
}
|
||||
insertTag(DISPLAYNAME) { text(displayName) }
|
||||
insertTag(COMPONENT_SET) {
|
||||
insertTag(COMP) { attribute(null, "name", COMPONENT) }
|
||||
}
|
||||
color?.let { insertTag(CALENDAR_COLOR) { text(hexOf(it)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun propPatchBody(displayName: String?, color: Int?): String = xml { serializer ->
|
||||
serializer.insertTag(PROPERTYUPDATE) {
|
||||
insertTag(SET) {
|
||||
insertTag(PROP) {
|
||||
displayName?.let { insertTag(DISPLAYNAME) { text(it) } }
|
||||
color?.let { insertTag(CALENDAR_COLOR) { text(hexOf(it)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun xml(build: (org.xmlpull.v1.XmlSerializer) -> Unit): String {
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.setPrefix("d", XmlUtils.NS_WEBDAV)
|
||||
serializer.setPrefix("c", XmlUtils.NS_CALDAV)
|
||||
serializer.setPrefix("i", XmlUtils.NS_APPLE_ICAL)
|
||||
serializer.startDocument("UTF-8", null)
|
||||
build(serializer)
|
||||
serializer.endDocument()
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ `#RRGGBBAA`, which is what Apple's `calendar-color` is and what
|
||||
* [at.bitfire.dav4jvm.property.CalendarColor] parses back. Writing the
|
||||
* packed ARGB `Int` verbatim yields "-65536" for red, which is not a colour
|
||||
* in any spelling.
|
||||
*/
|
||||
private fun hexOf(argb: Int): String = "#%06X%02X".format(argb and 0xFFFFFF, argb ushr 24)
|
||||
|
||||
/**
|
||||
* A comma-separated header, split and normalised.
|
||||
*
|
||||
* Repeated headers *and* comma lists, because servers use both spellings for
|
||||
* `DAV` and for `Allow`.
|
||||
*/
|
||||
private fun headerTokens(values: List<String>): Set<String> = values
|
||||
.flatMap { it.split(',') }
|
||||
.map { it.trim().uppercase() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
|
||||
private companion object {
|
||||
const val COMPONENT = "VTODO"
|
||||
const val NOT_FOUND = 404
|
||||
const val GONE = 410
|
||||
val MIME_XML = "application/xml; charset=utf-8".toMediaType()
|
||||
|
||||
val MKCALENDAR = Property.Name(XmlUtils.NS_CALDAV, "mkcalendar")
|
||||
val CALENDAR = Property.Name(XmlUtils.NS_CALDAV, "calendar")
|
||||
val COMPONENT_SET = Property.Name(XmlUtils.NS_CALDAV, "supported-calendar-component-set")
|
||||
val COMP = Property.Name(XmlUtils.NS_CALDAV, "comp")
|
||||
val MKCOL = Property.Name(XmlUtils.NS_WEBDAV, "mkcol")
|
||||
val SET = Property.Name(XmlUtils.NS_WEBDAV, "set")
|
||||
val PROP = Property.Name(XmlUtils.NS_WEBDAV, "prop")
|
||||
val PROPERTYUPDATE = Property.Name(XmlUtils.NS_WEBDAV, "propertyupdate")
|
||||
val DISPLAYNAME = Property.Name(XmlUtils.NS_WEBDAV, "displayname")
|
||||
val RESOURCETYPE = Property.Name(XmlUtils.NS_WEBDAV, "resourcetype")
|
||||
val COLLECTION = Property.Name(XmlUtils.NS_WEBDAV, "collection")
|
||||
val CALENDAR_COLOR = Property.Name(XmlUtils.NS_APPLE_ICAL, "calendar-color")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.CalendarColor
|
||||
import at.bitfire.dav4jvm.property.CurrentUserPrivilegeSet
|
||||
import at.bitfire.dav4jvm.property.DisplayName
|
||||
import at.bitfire.dav4jvm.property.MaxICalendarSize
|
||||
import at.bitfire.dav4jvm.property.ResourceType
|
||||
import at.bitfire.dav4jvm.property.SupportedCalendarComponentSet
|
||||
import at.bitfire.dav4jvm.property.SupportedReportSet
|
||||
import at.bitfire.dav4jvm.property.push.PushTransports
|
||||
import at.bitfire.dav4jvm.property.push.Topic
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/** A collection that survived classification, and what we know about it. */
|
||||
data class TaskCollection(
|
||||
val url: HttpUrl,
|
||||
val displayName: String?,
|
||||
/** Packed ARGB, the same form `task_lists.color` stores. */
|
||||
val color: Int?,
|
||||
val readOnly: Boolean,
|
||||
/**
|
||||
* A share rather than the user's own collection.
|
||||
*
|
||||
* Worth carrying: Nextcloud **rewrites task bodies on GET from a shared
|
||||
* calendar** — stripping `VALARM`, and reducing `CLASS:CONFIDENTIAL` to a
|
||||
* VEVENT-shaped whitelist — while leaving the ETag untouched. Re-PUTting what
|
||||
* we downloaded destroys the owner's task, so the engine needs to know.
|
||||
*/
|
||||
val isShared: Boolean,
|
||||
val supportsSyncCollection: Boolean,
|
||||
val maxResourceSize: Long?,
|
||||
/** WebDAV-Push over Web Push, when the server offers it for this collection. */
|
||||
val push: PushSupport? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Decides which collections in a Depth-1 listing can hold tasks.
|
||||
*
|
||||
* Both of the rules here are **inversions of the obvious one**, and they are
|
||||
* the important part of discovery. Getting either
|
||||
* backwards produces a client that silently finds nothing on a large fraction of
|
||||
* real servers, with no error to diagnose.
|
||||
*/
|
||||
object CollectionClassifier {
|
||||
|
||||
/**
|
||||
* The properties [classify] reads, requested **by name**.
|
||||
*
|
||||
* `allprop` legitimately omits every one of them (RFC 4791 §5.2.3 for the
|
||||
* component set, RFC 3744 §3.7 for the privileges), so asking for them by
|
||||
* name is not an optimisation — it is the only way to get them.
|
||||
*/
|
||||
val PROPERTIES = arrayOf(
|
||||
ResourceType.NAME,
|
||||
DisplayName.NAME,
|
||||
CalendarColor.NAME,
|
||||
SupportedCalendarComponentSet.NAME,
|
||||
SupportedReportSet.NAME,
|
||||
CurrentUserPrivilegeSet.NAME,
|
||||
MaxICalendarSize.NAME,
|
||||
PushTransports.NAME,
|
||||
Topic.NAME,
|
||||
)
|
||||
|
||||
/** `CS:shared`, which Nextcloud adds alongside `caldav:calendar` on a share. */
|
||||
private val SHARED = Property.Name("http://calendarserver.org/ns/", "shared")
|
||||
|
||||
fun classify(response: Response): TaskCollection? {
|
||||
val resourceType = response[ResourceType::class.java] ?: return null
|
||||
|
||||
// ⚠️ Positive test on an *unordered set*, never exclusion and never by
|
||||
// position. Excluding `schedule-outbox` — the obvious rule — drops SOGo's
|
||||
// main personal calendar, which reports collection + calendar +
|
||||
// schedule-outbox simultaneously for every non-Apple client, i.e. for us.
|
||||
// The positive rule also keeps shared calendars (they add CS:shared) and
|
||||
// still drops Nextcloud's nc:deleted-calendar, which deliberately strips
|
||||
// caldav:calendar.
|
||||
if (ResourceType.CALENDAR !in resourceType.types) return null
|
||||
|
||||
if (!supportsTasks(response)) return null
|
||||
|
||||
val privileges = response[CurrentUserPrivilegeSet::class.java]
|
||||
// Absent means writable. RFC 3744 §3.7 lets a server withhold the
|
||||
// property, and DAVx5 defaults to writable for the same reason: assuming
|
||||
// read-only hides collections the user can perfectly well write to.
|
||||
// A 403 on write is handled where it happens.
|
||||
val readOnly = privileges != null &&
|
||||
!privileges.mayWriteContent && !privileges.mayBind
|
||||
|
||||
val reports = response[SupportedReportSet::class.java]
|
||||
|
||||
return TaskCollection(
|
||||
url = response.href,
|
||||
displayName = response[DisplayName::class.java]?.displayName,
|
||||
// CalendarColor.color is a packed ARGB Int. Calling toString() on it
|
||||
// yields "-65536" for red — an unparseable decimal, not a colour.
|
||||
color = response[CalendarColor::class.java]?.color,
|
||||
readOnly = readOnly,
|
||||
isShared = SHARED in resourceType.types,
|
||||
// A hint, not a contract — Radicale advertised this for years without
|
||||
// implementing it, so the engine must still degrade gracefully.
|
||||
supportsSyncCollection = reports?.reports?.contains(SupportedReportSet.SYNC_COLLECTION) == true,
|
||||
maxResourceSize = response[MaxICalendarSize::class.java]?.maxSize,
|
||||
push = pushSupport(response),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push needs both halves: a Web Push transport to subscribe with and a topic
|
||||
* to recognise the messages by. Either alone is useless, so either alone is
|
||||
* no support at all.
|
||||
*/
|
||||
internal fun pushSupport(response: Response): PushSupport? {
|
||||
val webPush = response[PushTransports::class.java]?.webPush ?: return null
|
||||
val topic = response[Topic::class.java]?.topic ?: return null
|
||||
return PushSupport(topic = topic, vapidPublicKey = webPush.vapidPublicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* ⚠️ **An absent `supported-calendar-component-set` means "supports
|
||||
* everything", not "supports nothing".**
|
||||
*
|
||||
* RFC 4791 §5.2.3 says the property SHOULD NOT be returned from an allprop
|
||||
* request, so it is legitimately missing unless asked for **by name** — and
|
||||
* even then plenty of servers omit it. Keeping only collections whose set
|
||||
* *includes* VTODO therefore drops every server that does not advertise it.
|
||||
*
|
||||
* The grammar is `(comp+)`; an empty element is non-conformant, and
|
||||
* dav4jvm's parser starts all-`false`, so an empty one arrives
|
||||
* indistinguishable from "no VTODO". Treat that as all too — the cost of
|
||||
* being wrong is an empty collection list, and the cost of the other error is
|
||||
* a task list the user cannot reach.
|
||||
*/
|
||||
internal fun supportsTasks(response: Response): Boolean {
|
||||
val components = response[SupportedCalendarComponentSet::class.java] ?: return true
|
||||
val advertisesNothing = !components.supportsEvents &&
|
||||
!components.supportsTasks &&
|
||||
!components.supportsJournal
|
||||
if (advertisesNothing) return true
|
||||
return components.supportsTasks
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import org.xbill.DNS.Lookup
|
||||
import org.xbill.DNS.SRVRecord
|
||||
import org.xbill.DNS.TXTRecord
|
||||
import org.xbill.DNS.Type
|
||||
|
||||
/**
|
||||
* SRV/TXT lookups over dnsjava.
|
||||
*
|
||||
* Android exposes no usable SRV API — `DnsResolver` arrived in API 29 but is
|
||||
* callback-only and does not help with the `TXT path=` half — and JNDI's DNS
|
||||
* provider does not exist on Android at all. dnsjava is what DAVx5 uses.
|
||||
*
|
||||
* A lookup that fails returns an empty list rather than throwing: DNS being
|
||||
* unavailable means "no SRV record", which is an ordinary and common answer, and
|
||||
* the well-known ladder still has rungs left.
|
||||
*/
|
||||
class DnsJavaResolver : DnsResolver {
|
||||
|
||||
override fun srv(name: String): List<SrvRecord> = runCatching {
|
||||
Lookup(name, Type.SRV).run()
|
||||
.orEmpty()
|
||||
.filterIsInstance<SRVRecord>()
|
||||
.map { SrvRecord(it.priority, it.weight, it.port, it.target.toString(true)) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
override fun txt(name: String): List<String> = runCatching {
|
||||
Lookup(name, Type.TXT).run()
|
||||
.orEmpty()
|
||||
.filterIsInstance<TXTRecord>()
|
||||
.flatMap { it.strings }
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
/**
|
||||
* Nextcloud Login Flow v2.
|
||||
*
|
||||
* The user approves the app in a browser and the server mints a dedicated **app
|
||||
* password**, so the account password never touches this device and the grant can
|
||||
* be revoked from the server's own settings. It is not deprecated, there is no
|
||||
* v3, and OAuth2 is a worse fit.
|
||||
*
|
||||
* Every rule below is a correction from an audit of the first draft, and each
|
||||
* one turns a diagnosable error back into a diagnosable error.
|
||||
*/
|
||||
class NextcloudLoginFlow(
|
||||
private val httpClient: OkHttpClient,
|
||||
/**
|
||||
* ⚠️ Becomes the **app password's name** in Settings → Security → Devices &
|
||||
* sessions. With OkHttp's default the user sees `okhttp/4.12.0` and cannot
|
||||
* tell what to revoke — which defeats the entire point of the flow.
|
||||
*/
|
||||
private val userAgent: String,
|
||||
) {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
/** The 20-minute server-side lifetime (`lifetime = 1200` in `LoginFlowV2Mapper.php`). */
|
||||
val flowLifetimeSeconds = 1200L
|
||||
|
||||
/**
|
||||
* A started flow. Persist this **before** launching the browser: the flow
|
||||
* outlives our process, and Custom Tabs return no result when dismissed.
|
||||
*/
|
||||
data class Flow(
|
||||
val loginUrl: HttpUrl,
|
||||
val pollEndpoint: HttpUrl,
|
||||
val pollToken: String,
|
||||
val deadlineEpochSeconds: Long,
|
||||
/**
|
||||
* Set when the server sent us to a different host than the user typed.
|
||||
* **Carried, not thrown**: it is generated from `overwrite.cli.url` /
|
||||
* `overwriteprotocol` / `trusted_proxies` and is legitimate behind a
|
||||
* reverse proxy, which describes a large share of self-hosted installs.
|
||||
* Refusing outright would make Login Flow v2 unusable for them. The UI
|
||||
* confirms it with the user, quoting the cause.
|
||||
*/
|
||||
val hostMismatch: HostMismatch? = null,
|
||||
)
|
||||
|
||||
data class Credentials(val server: HttpUrl, val loginName: String, val appPassword: String)
|
||||
|
||||
sealed interface PollResult {
|
||||
data class Approved(
|
||||
val credentials: Credentials,
|
||||
/**
|
||||
* The server named an origin outside the one we polled, and we used
|
||||
* the one we polled instead. Reported so the user can be told which
|
||||
* setting is wrong, never acted on.
|
||||
*/
|
||||
val hostMismatch: HostMismatch? = null,
|
||||
) : PollResult
|
||||
/** 404: still waiting. Also what an expired or already-consumed flow returns. */
|
||||
data object Pending : PollResult
|
||||
data class Expired(val reason: String) : PollResult
|
||||
data class Failed(val cause: Cause, val reason: String) : PollResult
|
||||
|
||||
/**
|
||||
* Why the flow ended, in a form the UI can translate.
|
||||
*
|
||||
* ⚠️ The UI must render *this*, never [Failed.reason]. A server's own
|
||||
* words are untranslatable, often in a language the user does not read,
|
||||
* and here they are frequently a bare status line or a JSON parser's
|
||||
* complaint. [reason] exists for logs, and is never shown — the same rule
|
||||
* [CalDavDiscovery.Outcome.Cause] states for discovery.
|
||||
*/
|
||||
enum class Cause {
|
||||
/** Answering, but turning away repeated attempts (429). */
|
||||
RATE_LIMITED,
|
||||
|
||||
/** Down on purpose (503). */
|
||||
MAINTENANCE,
|
||||
|
||||
/** The server's own error, or an answer we could not read. */
|
||||
SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param server the base URL the user typed
|
||||
* @param now epoch seconds, for the locally tracked deadline
|
||||
*/
|
||||
fun start(server: HttpUrl, now: Long): Result<Flow> = runCatching {
|
||||
val request = Request.Builder()
|
||||
.url(server.newBuilder().addPathSegments(FLOW_START_PATH).build())
|
||||
.header("User-Agent", userAgent)
|
||||
// OCS-APIRequest is *not* needed: v2 is a Frontpage route, not OCS.
|
||||
.post(FormBody.Builder().build())
|
||||
.build()
|
||||
|
||||
Redirects.follow(httpClient, request).use { response ->
|
||||
if (!response.isSuccessful) error("login flow init failed: HTTP ${response.code}")
|
||||
val body = response.body?.string().orEmpty()
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val poll = root["poll"]?.jsonObject ?: error("no poll object in login flow response")
|
||||
|
||||
val endpointRaw = poll["endpoint"]?.jsonPrimitive?.content
|
||||
?: error("no poll endpoint")
|
||||
val endpoint = endpointRaw.toHttpUrlOrNull() ?: error("poll endpoint is not a URL")
|
||||
requireSecureOrigin(server, endpoint)
|
||||
|
||||
val loginUrl = root["login"]?.jsonPrimitive?.content?.toHttpUrlOrNull()
|
||||
?: error("no login URL")
|
||||
// ⚠️ The login URL is where the user types their **account** password,
|
||||
// in a browser. Validating only the poll endpoint leaves the more
|
||||
// dangerous of the two unchecked: a misconfigured or hostile server —
|
||||
// or a MITM on a typed http:// base — could point the browser at any
|
||||
// origin and harvest it.
|
||||
requireSecureOrigin(server, loginUrl)
|
||||
|
||||
Flow(
|
||||
loginUrl = loginUrl,
|
||||
pollEndpoint = endpoint,
|
||||
pollToken = poll["token"]?.jsonPrimitive?.content ?: error("no poll token"),
|
||||
deadlineEpochSeconds = now + flowLifetimeSeconds,
|
||||
hostMismatch = hostMismatchOf(server, endpoint) ?: hostMismatchOf(server, loginUrl),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll. The 200 is returned **exactly once** — the server deletes the row
|
||||
* inside `poll()` before returning — so the caller must persist the result
|
||||
* immediately.
|
||||
*/
|
||||
fun poll(flow: Flow, now: Long): PollResult {
|
||||
if (now > flow.deadlineEpochSeconds) {
|
||||
// 404 is also what an expired flow returns, so the deadline is tracked
|
||||
// locally or "expired" is indistinguishable from "still waiting".
|
||||
return PollResult.Expired("the 20-minute approval window has passed")
|
||||
}
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(flow.pollEndpoint)
|
||||
.header("User-Agent", userAgent)
|
||||
// ⚠️ POST, form-encoded. A GET gets 405.
|
||||
.post(FormBody.Builder().add("token", flow.pollToken).build())
|
||||
.build()
|
||||
|
||||
return runCatching {
|
||||
Redirects.follow(httpClient, request).use { response ->
|
||||
when {
|
||||
// ⚠️ 404 means pending, and *only* 404 does. "Anything that
|
||||
// isn't 200 is pending" swallows 429 (brute-force protection),
|
||||
// 503 (maintenance), a Cloudflare challenge page and every
|
||||
// DNS/TLS failure — turning a diagnosable error into a
|
||||
// twenty-minute spinner.
|
||||
response.code == 404 -> PollResult.Pending
|
||||
|
||||
response.code == 200 -> {
|
||||
val contentType = response.header("Content-Type").orEmpty()
|
||||
// A Cloudflare challenge is a 200 carrying HTML.
|
||||
if (!contentType.contains("application/json", ignoreCase = true)) {
|
||||
PollResult.Failed(
|
||||
PollResult.Cause.SERVER_ERROR,
|
||||
"server answered 200 with $contentType, not JSON",
|
||||
)
|
||||
} else {
|
||||
parseCredentials(flow, response.body?.string().orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
response.code == 429 ->
|
||||
PollResult.Failed(
|
||||
PollResult.Cause.RATE_LIMITED,
|
||||
"the server is rate-limiting this address (429)",
|
||||
)
|
||||
|
||||
response.code == 503 ->
|
||||
PollResult.Failed(
|
||||
PollResult.Cause.MAINTENANCE,
|
||||
"the server is in maintenance mode (503)",
|
||||
)
|
||||
|
||||
else -> PollResult.Failed(
|
||||
PollResult.Cause.SERVER_ERROR,
|
||||
"unexpected HTTP ${response.code}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}.getOrElse { PollResult.Failed(PollResult.Cause.SERVER_ERROR, it.message ?: it.toString()) }
|
||||
}
|
||||
|
||||
private fun parseCredentials(flow: Flow, body: String): PollResult = runCatching {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val server = root["server"]?.jsonPrimitive?.content?.toHttpUrlOrNull()
|
||||
?: error("no server URL in poll response")
|
||||
// ⚠️ 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 = reachableOrigin(flow.pollEndpoint, server)
|
||||
PollResult.Approved(
|
||||
hostMismatch = substitutedMismatch(flow.pollEndpoint, server, secureServer),
|
||||
credentials = Credentials(
|
||||
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
|
||||
// `remote.php/dav/calendars/<loginName>/` is the classic "logged
|
||||
// in but no calendars" bug; the principal comes from discovery.
|
||||
loginName = root["loginName"]?.jsonPrimitive?.content ?: error("no loginName"),
|
||||
appPassword = root["appPassword"]?.jsonPrimitive?.content ?: error("no appPassword"),
|
||||
),
|
||||
)
|
||||
}.getOrElse { PollResult.Failed(PollResult.Cause.SERVER_ERROR, it.message ?: it.toString()) }
|
||||
|
||||
/**
|
||||
* These URLs are generated from `overwrite.cli.url` / `overwriteprotocol` /
|
||||
* `trusted_proxies`, which are misconfigured on a large fraction of
|
||||
* self-hosted installs — so they are validated rather than trusted verbatim.
|
||||
*
|
||||
* 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) {
|
||||
error("the server returned an http:// URL (${actual.host}) for an https:// server")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// ⚠️ The port goes with the scheme. OkHttp only drops a *default*
|
||||
// port across a scheme change, so `http://cloud.example.com:8080/`
|
||||
// coerced to https keeps :8080 — a port that almost certainly speaks
|
||||
// cleartext, and the promised coercion becomes a TLS handshake
|
||||
// failure. The port that answered our poll is the one known to work,
|
||||
// and a claimed port emitted alongside a wrong scheme comes from the
|
||||
// same misconfiguration as the scheme did.
|
||||
actual.newBuilder().scheme("https").port(expected.port).build()
|
||||
} else {
|
||||
actual
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin to actually talk to, given what the poll response claimed.
|
||||
*
|
||||
* ⚠️ `server` and the poll endpoint come out of *different* generators in
|
||||
* Nextcloud: the poll endpoint honours `overwrite.cli.url`, while `server`
|
||||
* is built from the approving request's own protocol and Host header, which
|
||||
* respect `X-Forwarded-*` only once `trusted_proxies` is set. The ordinary
|
||||
* docker-compose-behind-nginx install therefore answers a perfectly good
|
||||
* `https://cloud.example.com` poll endpoint with
|
||||
* `"server": "http://nextcloud:11000"` — a name that does not resolve on the
|
||||
* phone. Following it means discovery fails, the account is never created,
|
||||
* and the app password is already spent.
|
||||
*
|
||||
* So when the claimed origin is outside the registrable domain we just
|
||||
* successfully polled, rebuild it entirely from the poll endpoint — scheme,
|
||||
* host, port *and* base path — which is the one URL empirically known to
|
||||
* answer. The claimed path is dropped with the claimed host: both come from
|
||||
* the same generator, and keeping half of a URL we have decided not to trust
|
||||
* is how a subdirectory install loses its prefix. The endpoint's own prefix
|
||||
* is whatever precedes `index.php/login/v2/poll`, which is where the flow
|
||||
* was opened. Coerced, never refused: by this point the credential exists
|
||||
* and the 200 is spent, so throwing burns a live app password.
|
||||
*
|
||||
* This compares one server-emitted origin against another, never against
|
||||
* what the user typed, so a correctly configured proxy — which emits the
|
||||
* same origin in both — is untouched. A sibling host under the same
|
||||
* registrable domain passes through, because that is a real deployment shape
|
||||
* and the credential is scoped to that domain anyway.
|
||||
*/
|
||||
internal fun reachableOrigin(expected: HttpUrl, actual: HttpUrl): HttpUrl {
|
||||
val secure = secureOrigin(expected, actual)
|
||||
// The same boundary the credential is scoped by; null for an IP literal
|
||||
// or a single-label host, where only the exact host will do.
|
||||
val claimed = secure.topPrivateDomain() ?: secure.host
|
||||
val polled = expected.topPrivateDomain() ?: expected.host
|
||||
if (claimed.equals(polled, ignoreCase = true)) return secure
|
||||
return expected.newBuilder()
|
||||
.encodedPath(baseOf(expected))
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FLOW_START_PATH = "index.php/login/v2"
|
||||
|
||||
/** What `start()` appends, plus the poll leg — with and without the front controller. */
|
||||
val FLOW_TAILS = listOf("index.php/login/v2/poll", "login/v2/poll")
|
||||
}
|
||||
|
||||
/**
|
||||
* The poll endpoint with the flow's own path removed — the server's web root.
|
||||
*
|
||||
* ⚠️ Both spellings. Nextcloud drops `index.php` from generated routes when
|
||||
* `htaccess.IgnoreFrontController` is on, so a subdirectory install can answer
|
||||
* `/nc/login/v2/poll` — and matching only the `index.php` form would return
|
||||
* "/" and lose the `/nc` prefix, which is the exact loss this function exists
|
||||
* to prevent.
|
||||
*/
|
||||
private fun baseOf(pollEndpoint: HttpUrl): String {
|
||||
val path = pollEndpoint.encodedPath
|
||||
val tail = FLOW_TAILS.map { path.indexOf(it) }.firstOrNull { it >= 0 } ?: return "/"
|
||||
return path.take(tail).ifEmpty { "/" }
|
||||
}
|
||||
|
||||
/**
|
||||
* The mismatch to report, which is only ever one we acted on.
|
||||
*
|
||||
* ⚠️ [hostMismatchOf] compares hosts exactly, while [reachableOrigin]
|
||||
* substitutes only when the *registrable domain* differs. So a server
|
||||
* answering `nc.example.com` for a poll endpoint on `cloud.example.com` is
|
||||
* used verbatim and correctly — and reporting that would tell the user we
|
||||
* replaced an address we did not, blaming a setting that is right. The note
|
||||
* is sticky, so it would follow them through the rest of the flow.
|
||||
*/
|
||||
internal fun substitutedMismatch(
|
||||
expected: HttpUrl,
|
||||
claimed: HttpUrl,
|
||||
used: HttpUrl,
|
||||
): HostMismatch? =
|
||||
hostMismatchOf(expected, claimed)?.takeIf { used.host != claimed.host }
|
||||
|
||||
/** 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
|
||||
|
||||
/** The server pointed the flow at a different host than the user typed. */
|
||||
data class HostMismatch(val expected: String, val actual: String) {
|
||||
val message: String
|
||||
get() = "the server sent us to \"$actual\" but you typed \"$expected\" — " +
|
||||
"its overwrite.cli.url is probably wrong"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* Redirect following for the plain-HTTP corners of this module.
|
||||
*
|
||||
* ⚠️ Every client here is built with `followRedirects(false)`, because
|
||||
* `DavResource` requires it and asserts on it — so the DAV calls follow by hand
|
||||
* and the two OCS/JSON ones did not follow at all. A Nextcloud that
|
||||
* canonicalises host or path with a 301 (apex→www, a trailing slash, a proxy)
|
||||
* therefore turned the login flow's `start` into a bare failure, its `poll` into
|
||||
* an unexplained SERVER_ERROR *after* the password was minted, and the app
|
||||
* password revocation into the "attempted, and did nothing" outcome its own
|
||||
* KDoc exists to prevent.
|
||||
*
|
||||
* ⚠️ The method and body are **re-issued as they were**, which is 307/308
|
||||
* behaviour rather than the classic 301/302 POST→GET rewrite. Both endpoints
|
||||
* answer 405 to a GET, so rewriting the method would turn one silent failure
|
||||
* into another; and the bodies here are in-memory forms, so replaying costs
|
||||
* nothing.
|
||||
*/
|
||||
internal object Redirects {
|
||||
|
||||
/** The same ceiling `DavResource` uses, for the same reason. */
|
||||
const val MAX_HOPS = 5
|
||||
|
||||
@Throws(IOException::class)
|
||||
fun follow(client: OkHttpClient, request: Request): Response {
|
||||
var current = request
|
||||
repeat(MAX_HOPS) {
|
||||
val response = client.newCall(current).execute()
|
||||
if (!response.isRedirect) return response
|
||||
val target = response.header("Location")?.let { current.url.resolve(it) }
|
||||
response.close()
|
||||
if (target == null) throw IOException("redirect with no usable Location from ${current.url}")
|
||||
current = current.newBuilder().url(secure(current.url, target)).build()
|
||||
}
|
||||
throw IOException("more than $MAX_HOPS redirects from ${request.url}")
|
||||
}
|
||||
|
||||
/**
|
||||
* The downgrade rule `DavResource` already applies, stated once more here.
|
||||
*
|
||||
* A downgrade to the *same host* is the single most common misconfiguration
|
||||
* in this space — a Nextcloud behind a TLS-terminating proxy with no
|
||||
* `overwriteprotocol` builds every redirect with `http://` — and re-issuing
|
||||
* over TLS is strictly safer than what we were asked to do. A downgrade to a
|
||||
* different host has no innocent reading.
|
||||
*
|
||||
* ⚠️ The port goes with the scheme: OkHttp only drops a *default* port
|
||||
* across a scheme change, so a redirect to `http://host:8080` would
|
||||
* otherwise be re-issued as `https://host:8080`, which almost certainly
|
||||
* speaks cleartext. The port we were already talking to is the one known to
|
||||
* answer.
|
||||
*/
|
||||
private fun secure(from: HttpUrl, to: HttpUrl): HttpUrl = when {
|
||||
!from.isHttps || to.isHttps -> to
|
||||
to.host.equals(from.host, ignoreCase = true) ->
|
||||
to.newBuilder().scheme("https").port(from.port).build()
|
||||
else -> throw IOException("refusing a redirect from HTTPS to HTTP at ${to.host}")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* The remote side of one task collection, as the sync engine needs it.
|
||||
*
|
||||
* A seam, and a load-bearing one: the reconciliation above this interface is
|
||||
* where local edits get discarded, tombstones get swept and conflicts get
|
||||
* resolved, and none of that should need a server — or a network — to exercise.
|
||||
* [CalendarCollection] is the only production implementation.
|
||||
*/
|
||||
interface RemoteCalendar {
|
||||
|
||||
val url: HttpUrl
|
||||
|
||||
fun state(): Result<CalendarCollection.State>
|
||||
|
||||
fun list(): Result<List<RemoteRef>>
|
||||
|
||||
/**
|
||||
* One page of changes since [token], or null for a first run.
|
||||
*
|
||||
* Never throws for anything a server can legitimately answer — a rejected
|
||||
* token is [ChangeSet.TokenInvalid], not an exception.
|
||||
*/
|
||||
fun changes(token: String?): ChangeSet
|
||||
|
||||
fun fetch(hrefs: List<HttpUrl>): Result<FetchResult>
|
||||
|
||||
fun create(name: String, iCalendar: String): PutOutcome
|
||||
|
||||
/**
|
||||
* Replaces [href]. A null [eTag] writes **unconditionally**, which is only
|
||||
* ever correct when the server offers no usable validator at all — see
|
||||
* [ETag].
|
||||
*/
|
||||
fun update(href: HttpUrl, eTag: String?, iCalendar: String): PutOutcome
|
||||
|
||||
fun delete(href: HttpUrl, eTag: String?): DeleteOutcome
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Filenames for calendar resources.
|
||||
*
|
||||
* ⚠️ **An href is not a UID.** A UID is opaque text chosen by whoever created
|
||||
* the task — it may contain slashes, spaces, percent signs or nothing printable
|
||||
* at all — while an href is a path segment on someone else's filesystem. Using
|
||||
* one as the other is the classic CalDAV client bug: it works against the server
|
||||
* you tested on and produces 400s, 403s or silently truncated names elsewhere.
|
||||
*
|
||||
* The character class is vdirsyncer's, and the exclusion of `@` is deliberate:
|
||||
* plenty of UIDs are email-shaped, and `@` in a path segment is legal but
|
||||
* routinely mishandled by proxies and by servers that re-parse their own URLs.
|
||||
*/
|
||||
object ResourceNames {
|
||||
|
||||
/**
|
||||
* Cap on the basename, in bytes.
|
||||
*
|
||||
* Well under the 255 that most filesystems allow, because the server appends
|
||||
* to what we send — Nextcloud's trashbin renames a deleted resource to
|
||||
* `<name>-deleted.ics`, and a name that only just fit before deletion does
|
||||
* not fit afterwards.
|
||||
*/
|
||||
const val MAX_BASENAME_BYTES = 200
|
||||
|
||||
const val EXTENSION = ".ics"
|
||||
|
||||
private val UNSAFE = Regex("[^A-Za-z0-9_.+-]")
|
||||
|
||||
/**
|
||||
* A filename derived from [uid], or a random one when [uid] does not survive
|
||||
* sanitisation.
|
||||
*
|
||||
* The derivation is a convenience for humans reading the collection over
|
||||
* WebDAV, never an identity: nothing reads the UID back out of an href.
|
||||
*/
|
||||
fun forUid(uid: String): String {
|
||||
val cleaned = UNSAFE.replace(uid, "-")
|
||||
.trim('-', '.')
|
||||
.take(MAX_BASENAME_BYTES)
|
||||
.trimEnd('-', '.')
|
||||
return if (cleaned.isEmpty() || cleaned.all { it == '-' }) random() else cleaned + EXTENSION
|
||||
}
|
||||
|
||||
/** A fresh name that cannot collide, for the fallback and the 412 retry. */
|
||||
fun random(): String = UUID.randomUUID().toString() + EXTENSION
|
||||
|
||||
/**
|
||||
* A path segment for a new **collection**, derived from what the user called
|
||||
* it.
|
||||
*
|
||||
* No extension: a collection is a directory, not a file. Derived rather than
|
||||
* random for the same reason [forUid] is — someone browsing the account over
|
||||
* WebDAV, or in their server's own web UI, should be able to tell which of
|
||||
* these is "Shopping" — and, like [forUid], it is a convenience and never an
|
||||
* identity: the href is what identifies the collection afterwards.
|
||||
*
|
||||
* ⚠️ Sanitised to the same class, which matters more here than it does for a
|
||||
* resource: a display name is typed by a person, in their own language, and
|
||||
* "Einkäufe 🛒" is an ordinary thing to call a list. What survives may be
|
||||
* empty, and then a random segment is the honest answer.
|
||||
*/
|
||||
fun forCollection(displayName: String): String {
|
||||
val cleaned = UNSAFE.replace(displayName, "-")
|
||||
.trim('-', '.')
|
||||
.take(MAX_BASENAME_BYTES)
|
||||
.trimEnd('-', '.')
|
||||
return if (cleaned.isEmpty() || cleaned.all { it == '-' }) randomCollection() else cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* A collection segment that cannot collide.
|
||||
*
|
||||
* ⚠️ Also the retry, and Nextcloud is why it has to exist rather than being
|
||||
* a fallback for unprintable names: its trashbin renames a deleted
|
||||
* collection instead of removing it, so re-creating one under a name that
|
||||
* was used before answers 403 for ever. A fresh segment is the only way
|
||||
* back, and the display name is unaffected — two lists may share one.
|
||||
*/
|
||||
fun randomCollection(): String = UUID.randomUUID().toString()
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* What we know about a server before talking to it.
|
||||
*
|
||||
* This exists for one reason: *"wrong password" that is actually "you used your
|
||||
* 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 {
|
||||
|
||||
/** Fastmail: needs an app password, and CalDAV is not on the Basic plan. */
|
||||
FASTMAIL_APP_PASSWORD,
|
||||
|
||||
/** iCloud: app-specific password, and 2FA must be on to mint one. */
|
||||
ICLOUD_APP_SPECIFIC_PASSWORD,
|
||||
|
||||
/**
|
||||
* Google: OAuth2-only, and it supports neither VTODO nor MKCALENDAR — its own
|
||||
* documentation says *"Doesn't support VTODO or VJOURNAL data"*. It is
|
||||
* not a target, so this is a refusal with an explanation rather than
|
||||
* a 401 the user cannot act on.
|
||||
*/
|
||||
GOOGLE_UNSUPPORTED,
|
||||
|
||||
/**
|
||||
* Nextcloud's brute-force protection throttles then 429s **per source IP**, so
|
||||
* a retry loop on a dead app password takes down the user's other Nextcloud
|
||||
* 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,
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
/** The quirk implied by an email address or a URL host, if any. */
|
||||
fun forInput(input: String): ServerQuirk? = CalDavProvider.forInput(input)?.quirk
|
||||
|
||||
fun forHost(host: String): ServerQuirk? = CalDavProvider.forHost(host)?.quirk
|
||||
|
||||
/** What is known about a service the user picked by name rather than typed. */
|
||||
fun forProvider(provider: CalDavProvider): ServerQuirk? = provider.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. */
|
||||
val isFatal: Boolean get() = this == GOOGLE_UNSUPPORTED
|
||||
|
||||
/**
|
||||
* True when the quirk is an errand the user can actually run — go to the
|
||||
* provider, mint an app password, come back — rather than a refusal or a
|
||||
* note for the engine.
|
||||
*
|
||||
* What separates the two is whether there is anything to *do*. Google is a
|
||||
* dead end and the brute-force note is never shown, so neither earns the
|
||||
* screen that walks through the steps.
|
||||
*/
|
||||
val hasSetupSteps: Boolean
|
||||
get() = this == FASTMAIL_APP_PASSWORD || this == ICLOUD_APP_SPECIFIC_PASSWORD
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/** One `_caldavs._tcp` SRV record. */
|
||||
data class SrvRecord(
|
||||
val priority: Int,
|
||||
val weight: Int,
|
||||
val port: Int,
|
||||
val target: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* DNS lookups for RFC 6764. Behind an interface so the pipeline is testable
|
||||
* without a network, and so the resolver can be swapped per platform.
|
||||
*/
|
||||
interface DnsResolver {
|
||||
fun srv(name: String): List<SrvRecord>
|
||||
fun txt(name: String): List<String>
|
||||
|
||||
/** For the base-URL path, where DNS is never consulted. */
|
||||
object None : DnsResolver {
|
||||
override fun srv(name: String) = emptyList<SrvRecord>()
|
||||
override fun txt(name: String) = emptyList<String>()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns what the user typed into the ordered list of URLs worth probing.
|
||||
*
|
||||
* This is RFC 6764 §6 (`SRV` + `TXT`) followed by the well-known ladder.
|
||||
* Every rule below cost
|
||||
* somebody a support ticket:
|
||||
*
|
||||
* - **`TXT path=` is not optional.** Posteo publishes `path=/`, GMX and Web.de
|
||||
* `path=/begenda/dav/users/`. Skipping the TXT lookup lands both on the wrong
|
||||
* path and discovery finds nothing.
|
||||
* - **The SRV port is not 443.** Posteo is SRV-only on **8443**, and its
|
||||
* `/.well-known/caldav` 404s. Hardcoding the port fails it outright.
|
||||
* - **A `.` target means "explicitly unavailable"** (RFC 2782), not "no record".
|
||||
* `_caldav._tcp.fastmail.com` and `runbox.com` both answer `0 0 0 .`.
|
||||
* - **`/` is the last rung**, after the TXT path and `/.well-known/caldav`. The
|
||||
* draft omitted it.
|
||||
* - **Priority and weight are honoured**, lowest priority first.
|
||||
*/
|
||||
object ServiceDiscovery {
|
||||
|
||||
private const val SRV_SERVICE = "_caldavs._tcp"
|
||||
private const val WELL_KNOWN = "/.well-known/caldav"
|
||||
|
||||
/** A URL to probe, and where it came from — the "why" a failure report needs. */
|
||||
data class Candidate(val url: HttpUrl, val origin: String)
|
||||
|
||||
/**
|
||||
* Hosts whose SRV record leads somewhere that is not a DAV server.
|
||||
*
|
||||
* Google publishes a valid `_caldavs._tcp` record pointing at
|
||||
* `calendar.google.com`, which answers **405** to PROPFIND. A strict RFC 6764
|
||||
* client follows it into a dead end for every `@gmail.com` address. Google is
|
||||
* out of scope anyway — it supports neither VTODO nor MKCALENDAR — so this is
|
||||
* caught at the front rather than surfaced as a baffling 405.
|
||||
*/
|
||||
private val SRV_DEAD_ENDS = setOf("calendar.google.com")
|
||||
|
||||
/**
|
||||
* @param input what the user typed: an email address, a `mailto:` URI, or a
|
||||
* base URL
|
||||
*/
|
||||
fun candidatesFor(input: String, dns: DnsResolver = DnsResolver.None): List<Candidate> {
|
||||
val trimmed = input.trim()
|
||||
if (trimmed.isEmpty()) return emptyList()
|
||||
|
||||
// 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>()
|
||||
|
||||
val srv = dns.srv("$SRV_SERVICE.$domain")
|
||||
.filterNot { it.target == "." || it.target.isEmpty() || it.port == 0 }
|
||||
// DNS names are case-insensitive and dnsjava returns the wire case, so
|
||||
// "Calendar.Google.com." would otherwise walk straight past the guard.
|
||||
.filterNot { it.target.trimEnd('.').lowercase() in SRV_DEAD_ENDS }
|
||||
// ⚠️ RFC 2782 does not require a target to live inside the domain it
|
||||
// was queried for, and this ladder is walked over plain UDP DNS with
|
||||
// no DNSSEC — then walked *again* after a 401, with the
|
||||
// authenticated client. An out-of-domain target is also one the
|
||||
// credential would never be offered to, since CalDavHttp scopes it
|
||||
// to the typed address's registrable domain, so following it can
|
||||
// only end in a 401 the user cannot act on. Refusing leaves the
|
||||
// well-known ladder on the typed domain, which is where a correctly
|
||||
// delegated install answers anyway.
|
||||
.filter { sharesDomain(it.target, domain) }
|
||||
.sortedWith(compareBy({ it.priority }, { -it.weight }))
|
||||
|
||||
// The TXT path applies to the SRV target, and to the bare domain when
|
||||
// there is no SRV record — that is the GMX/Web.de shape.
|
||||
val txtPath = dns.txt("$SRV_SERVICE.$domain")
|
||||
.firstNotNullOfOrNull { record ->
|
||||
record.split(' ', ';')
|
||||
.firstOrNull { it.startsWith("path=", ignoreCase = true) }
|
||||
?.substringAfter('=')
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
val origins = srv.map { record ->
|
||||
val host = record.target.trimEnd('.').lowercase()
|
||||
// Port 443 is the default and stays implicit; anything else is
|
||||
// explicit, which is the whole point of Posteo's 8443.
|
||||
val port = if (record.port == 443) "" else ":${record.port}"
|
||||
"https://$host$port" to "SRV $host$port"
|
||||
}.ifEmpty {
|
||||
listOf("https://$domain" to "domain as typed")
|
||||
}
|
||||
|
||||
for ((origin, label) in origins) {
|
||||
if (txtPath != null) {
|
||||
add(candidates, origin, txtPath, "$label + TXT path=$txtPath")
|
||||
}
|
||||
add(candidates, origin, WELL_KNOWN, "$label + .well-known")
|
||||
add(candidates, origin, "/", "$label + root")
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an SRV target may stand in for [domain] — the same registrable
|
||||
* domain, or the exact host where there is none (an IP literal, a
|
||||
* single-label name, a host that *is* a public suffix).
|
||||
*/
|
||||
private fun sharesDomain(target: String, domain: String): Boolean {
|
||||
val host = target.trimEnd('.').lowercase()
|
||||
val scope = scopeOf(domain) ?: return host.equals(domain, ignoreCase = true)
|
||||
return scopeOf(host)?.equals(scope, ignoreCase = true) == true
|
||||
}
|
||||
|
||||
/** The boundary `CalDavHttp` scopes a credential by, derived the same way. */
|
||||
private fun scopeOf(host: String): String? {
|
||||
val literal = if (':' in host) "[$host]" else host
|
||||
val url = "https://$literal".toHttpUrlOrNull() ?: return null
|
||||
return url.topPrivateDomain() ?: url.host
|
||||
}
|
||||
|
||||
private fun add(into: MutableList<Candidate>, origin: String, path: String, label: String) {
|
||||
val url = (origin.trimEnd('/') + "/" + path.trimStart('/')).toHttpUrlOrNull() ?: return
|
||||
if (into.none { it.url == url }) into += Candidate(url, label)
|
||||
}
|
||||
|
||||
/**
|
||||
* The server **root** for [input] — the origin, not the DAV path.
|
||||
*
|
||||
* Nextcloud's Login Flow v2 lives at `index.php/login/v2` off the root, so it
|
||||
* needs this rather than a discovered collection URL. A typed base URL is
|
||||
* taken as typed; an email address becomes `https://<domain>`.
|
||||
*/
|
||||
fun serverRootFor(input: String): HttpUrl? {
|
||||
val trimmed = input.trim()
|
||||
asBaseUrl(trimmed)?.let { return it }
|
||||
return domainOf(trimmed)?.let { "https://$it".toHttpUrlOrNull() }
|
||||
}
|
||||
|
||||
/** The input as a base URL, or null if it is an address rather than a URL. */
|
||||
internal fun asBaseUrl(input: String): HttpUrl? {
|
||||
if (!input.startsWith("http://", ignoreCase = true) &&
|
||||
!input.startsWith("https://", ignoreCase = true)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return input.toHttpUrlOrNull()
|
||||
}
|
||||
|
||||
/** The domain of an email address, a `mailto:` URI, or a bare domain. */
|
||||
internal fun domainOf(input: String): String? {
|
||||
val withoutScheme = input.removePrefix("mailto:").removePrefix("MAILTO:")
|
||||
val domain = withoutScheme.substringAfterLast('@').trim().trimEnd('.')
|
||||
return domain.takeIf { it.isNotEmpty() && it.contains('.') && !it.contains('/') }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import at.bitfire.dav4jvm.DavCollection
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.HttpUtils
|
||||
import at.bitfire.dav4jvm.Property
|
||||
import at.bitfire.dav4jvm.XmlUtils
|
||||
import at.bitfire.dav4jvm.XmlUtils.insertTag
|
||||
import at.bitfire.dav4jvm.XmlUtils.propertyName
|
||||
import at.bitfire.dav4jvm.exception.DavException
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.push.WebDAVPush
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserException
|
||||
import java.io.IOException
|
||||
import java.io.StringReader
|
||||
import java.io.StringWriter
|
||||
import java.util.Date
|
||||
import kotlin.time.Instant
|
||||
|
||||
/** What a collection's server offers for WebDAV-Push over Web Push. */
|
||||
data class PushSupport(
|
||||
/** Carried by every push message about this collection. */
|
||||
val topic: String,
|
||||
/** The server's VAPID key; absent means unauthenticated pushes. */
|
||||
val vapidPublicKey: String?,
|
||||
)
|
||||
|
||||
/**
|
||||
* The client side of the WebDAV-Push draft (https://github.com/bitfireAT/webdav-push):
|
||||
* subscribing, unsubscribing and reading messages. Returns outcomes, never throws.
|
||||
*/
|
||||
object WebDavPush {
|
||||
|
||||
sealed interface Registration {
|
||||
/** Accepted. [url] is where it lives; null if the server sent no `Location`. */
|
||||
data class Registered(val url: HttpUrl?, val expires: Instant) : Registration
|
||||
|
||||
/** A definite no — push unavailable here, or our subscription refused. */
|
||||
data class Refused(val code: Int) : Registration
|
||||
|
||||
/** Nothing definite: the network, or a server error worth retrying. */
|
||||
data class Failed(val reason: String) : Registration
|
||||
}
|
||||
|
||||
/** A decoded push message. */
|
||||
data class Message(
|
||||
/** Which resource changed; null only for a VAPID key rotation. */
|
||||
val topic: String?,
|
||||
/** The collection's sync token after the change, when the server says. */
|
||||
val syncToken: String?,
|
||||
/** The server's VAPID key changed and every subscription must be renewed. */
|
||||
val keyRotation: Boolean,
|
||||
)
|
||||
|
||||
/**
|
||||
* Subscribes [endpoint] to changes in [collection] and its members. Registering
|
||||
* the same endpoint again renews it.
|
||||
*
|
||||
* @param publicKey the subscription's p256dh key, base64url.
|
||||
* @param authSecret the subscription's auth secret, base64url; sent only with [publicKey].
|
||||
*/
|
||||
fun register(
|
||||
client: OkHttpClient,
|
||||
collection: HttpUrl,
|
||||
endpoint: String,
|
||||
publicKey: String?,
|
||||
authSecret: String?,
|
||||
expires: Instant,
|
||||
): Registration = try {
|
||||
var result: Registration = Registration.Failed("no response")
|
||||
val body = registerBody(endpoint, publicKey, authSecret, expires).toRequestBody(MIME_XML)
|
||||
DavCollection(client, collection).post(body) { response ->
|
||||
val location = response.header("Location")?.let { collection.resolve(it) }
|
||||
// The server may shorten what we asked for.
|
||||
val granted = response.header("Expires")
|
||||
?.let { HttpUtils.parseDate(it) }
|
||||
?.let { Instant.fromEpochMilliseconds(it.time) }
|
||||
result = Registration.Registered(location, granted ?: expires)
|
||||
}
|
||||
result
|
||||
} catch (e: HttpException) {
|
||||
if (e.code / 100 == 4) {
|
||||
Registration.Refused(e.code)
|
||||
} else {
|
||||
Registration.Failed(listOfNotNull("HTTP ${e.code}", serverMessage(e.responseBody)).joinToString(": "))
|
||||
}
|
||||
} catch (e: DavException) {
|
||||
Registration.Failed(e.toString())
|
||||
} catch (e: IOException) {
|
||||
Registration.Failed(e.toString())
|
||||
}
|
||||
|
||||
/** The exception text sabre-based servers put in an error body, for the log. */
|
||||
internal fun serverMessage(body: String?): String? =
|
||||
body?.let { MESSAGE.find(it)?.groupValues?.get(1)?.trim() }?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/** Removes the subscription at [registration]; false only when it may still exist. */
|
||||
fun unregister(client: OkHttpClient, registration: HttpUrl): Boolean = try {
|
||||
DavResource(client, registration).delete { }
|
||||
true
|
||||
} catch (e: HttpException) {
|
||||
e.code == NOT_FOUND || e.code == GONE
|
||||
} catch (_: DavException) {
|
||||
false
|
||||
} catch (_: IOException) {
|
||||
false
|
||||
}
|
||||
|
||||
/** `Push-Dont-Notify` for our own writes, so they do not wake us. */
|
||||
fun dontNotifyHeader(registration: HttpUrl): String = "\"$registration\""
|
||||
|
||||
/** Decodes a decrypted push message, or null when it is not one. */
|
||||
fun parse(message: String): Message? = try {
|
||||
val parser = XmlUtils.newPullParser()
|
||||
parser.setInput(StringReader(message))
|
||||
var result: Message? = null
|
||||
var eventType = parser.eventType
|
||||
while (eventType != XmlPullParser.END_DOCUMENT && result == null) {
|
||||
if (eventType == XmlPullParser.START_TAG && parser.propertyName() == WebDAVPush.PushMessage) {
|
||||
result = readMessage(parser)
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
result
|
||||
} catch (_: XmlPullParserException) {
|
||||
null
|
||||
} catch (_: IOException) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun readMessage(parser: XmlPullParser): Message {
|
||||
var topic: String? = null
|
||||
var syncToken: String? = null
|
||||
var keyRotation = false
|
||||
val depth = parser.depth
|
||||
var eventType = parser.eventType
|
||||
while (!(eventType == XmlPullParser.END_TAG && parser.depth == depth)) {
|
||||
if (eventType == XmlPullParser.START_TAG) {
|
||||
when (parser.propertyName()) {
|
||||
WebDAVPush.Topic -> topic = XmlUtils.readText(parser)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
SYNC_TOKEN -> syncToken = XmlUtils.readText(parser)?.trim()?.takeIf { it.isNotEmpty() }
|
||||
// A key rotation names the transports property.
|
||||
WebDAVPush.Transports -> keyRotation = true
|
||||
}
|
||||
}
|
||||
eventType = parser.next()
|
||||
}
|
||||
return Message(topic = topic, syncToken = syncToken, keyRotation = keyRotation && topic == null)
|
||||
}
|
||||
|
||||
internal fun registerBody(
|
||||
endpoint: String,
|
||||
publicKey: String?,
|
||||
authSecret: String?,
|
||||
expires: Instant,
|
||||
): String {
|
||||
val serializer = XmlUtils.newSerializer()
|
||||
val writer = StringWriter()
|
||||
serializer.setOutput(writer)
|
||||
serializer.startDocument("UTF-8", true)
|
||||
serializer.setPrefix("", WebDAVPush.NS_WEBDAV_PUSH)
|
||||
serializer.setPrefix("D", XmlUtils.NS_WEBDAV)
|
||||
serializer.insertTag(WebDAVPush.PushRegister) {
|
||||
insertTag(WebDAVPush.Subscription) {
|
||||
insertTag(WebDAVPush.WebPushSubscription) {
|
||||
insertTag(WebDAVPush.PushResource) { text(endpoint) }
|
||||
if (publicKey != null && authSecret != null) {
|
||||
insertTag(WebDAVPush.ContentEncoding) { text("aes128gcm") }
|
||||
insertTag(WebDAVPush.SubscriptionPublicKey) {
|
||||
attribute(null, "type", "p256dh")
|
||||
text(publicKey)
|
||||
}
|
||||
insertTag(WebDAVPush.AuthSecret) { text(authSecret) }
|
||||
}
|
||||
}
|
||||
}
|
||||
// Depth 1 covers the tasks; property changes are left to the periodic sync.
|
||||
insertTag(WebDAVPush.Trigger) {
|
||||
insertTag(WebDAVPush.ContentUpdate) {
|
||||
insertTag(DEPTH) { text("1") }
|
||||
}
|
||||
}
|
||||
insertTag(WebDAVPush.Expires) {
|
||||
text(HttpUtils.formatDate(Date(expires.toEpochMilliseconds())))
|
||||
}
|
||||
}
|
||||
serializer.endDocument()
|
||||
return writer.toString()
|
||||
}
|
||||
|
||||
private val MIME_XML = "application/xml; charset=utf-8".toMediaType()
|
||||
private val DEPTH = Property.Name(XmlUtils.NS_WEBDAV, "depth")
|
||||
private val SYNC_TOKEN = Property.Name(XmlUtils.NS_WEBDAV, "sync-token")
|
||||
private val MESSAGE = Regex("<(?:[A-Za-z0-9]+:)?message>(.*?)</(?:[A-Za-z0-9]+:)?message>", RegexOption.DOT_MATCHES_ALL)
|
||||
private const val NOT_FOUND = 404
|
||||
private const val GONE = 410
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.SocketPolicy
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.nanoseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class AppPasswordTest {
|
||||
|
||||
private val server = MockWebServer()
|
||||
private val httpClient = OkHttpClient()
|
||||
|
||||
@Before fun start() = server.start()
|
||||
|
||||
@After fun stop() = server.shutdown()
|
||||
|
||||
@Test fun `a redirected revocation is followed`() {
|
||||
// Production builds its clients with followRedirects(false), so a server
|
||||
// that canonicalises host or path leaves the password dangling —
|
||||
// attempted, and doing nothing.
|
||||
val noRedirects = OkHttpClient.Builder().followRedirects(false).build()
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(301)
|
||||
.setHeader("Location", server.url("/nc/ocs/v2.php/core/apppassword").toString()),
|
||||
)
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val revoked = AppPassword.revokeAt(noRedirects, server.url("/"))
|
||||
|
||||
assertThat(revoked).isTrue()
|
||||
server.takeRequest()
|
||||
val followed = server.takeRequest()
|
||||
assertThat(followed.method).isEqualTo("DELETE")
|
||||
assertThat(followed.getHeader("OCS-APIRequest")).isEqualTo("true")
|
||||
}
|
||||
|
||||
@Test fun `the OCS root is derived from a principal, not appended to it`() {
|
||||
// ⚠️ Appending to the principal produces a path that 404s on every
|
||||
// server, silently — the revocation looks attempted and does nothing.
|
||||
val principal = "https://cloud.example.com/remote.php/dav/principals/users/alice/"
|
||||
assertThat(AppPassword.ocsRootFor(principal.toHttpUrl()).toString())
|
||||
.isEqualTo("https://cloud.example.com/")
|
||||
}
|
||||
|
||||
@Test fun `a subpath install keeps its subpath`() {
|
||||
val principal = "https://host/nextcloud/remote.php/dav/principals/users/alice/"
|
||||
assertThat(AppPassword.ocsRootFor(principal.toHttpUrl()).toString())
|
||||
.isEqualTo("https://host/nextcloud/")
|
||||
}
|
||||
|
||||
@Test fun `a principal with no remote_php falls back to the origin`() {
|
||||
val principal = "https://baikal.example.com/dav.php/principals/alice/"
|
||||
assertThat(AppPassword.ocsRootFor(principal.toHttpUrl()).toString())
|
||||
.isEqualTo("https://baikal.example.com/")
|
||||
}
|
||||
|
||||
@Test fun `a server root is used verbatim, not treated as a principal`() {
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val revoked = AppPassword.revokeAt(httpClient, server.url("/nextcloud/"))
|
||||
|
||||
// ⚠️ ocsRootFor looks for remote.php and falls back to the bare origin.
|
||||
// A login flow hands back a server base, so routing one through revoke()
|
||||
// would collapse /nextcloud/ to / and DELETE a path that 404s on every
|
||||
// subpath install — attempted, and doing nothing.
|
||||
assertThat(revoked).isTrue()
|
||||
assertThat(server.takeRequest().path)
|
||||
.isEqualTo("/nextcloud/ocs/v2.php/core/apppassword")
|
||||
}
|
||||
|
||||
@Test fun `a server that never answers does not hold the removal`() {
|
||||
// Accepts the connection and says nothing — the off-VPN homelab shape.
|
||||
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))
|
||||
|
||||
val startedAt = System.nanoTime()
|
||||
val revoked = AppPassword.revoke(
|
||||
httpClient,
|
||||
server.url("/remote.php/dav/principals/users/alice/"),
|
||||
timeout = 200.milliseconds,
|
||||
)
|
||||
val elapsed = (System.nanoTime() - startedAt).nanoseconds
|
||||
|
||||
// ⚠️ The budget has to live on the call. execute() parks on a socket read
|
||||
// that neither coroutine cancellation nor Thread.interrupt can break, so
|
||||
// a timeout imposed from outside returns only once the read has finished
|
||||
// anyway — after the shared client's own minutes-long budget.
|
||||
assertThat(revoked).isFalse()
|
||||
assertThat(elapsed).isLessThan(2.seconds)
|
||||
}
|
||||
|
||||
@Test fun `the default budget is what the removal is willing to wait`() {
|
||||
assertThat(AppPassword.REVOCATION_TIMEOUT).isEqualTo(5.seconds)
|
||||
}
|
||||
|
||||
@Test fun `the revocation is a DELETE with the OCS header`() {
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
val revoked = AppPassword.revoke(
|
||||
httpClient,
|
||||
server.url("/remote.php/dav/principals/users/alice/"),
|
||||
)
|
||||
|
||||
assertThat(revoked).isTrue()
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.method).isEqualTo("DELETE")
|
||||
assertThat(request.path).isEqualTo("/ocs/v2.php/core/apppassword")
|
||||
// ⚠️ Without this header Nextcloud answers with a CSRF complaint that
|
||||
// reads exactly like a wrong password.
|
||||
assertThat(request.getHeader("OCS-APIRequest")).isEqualTo("true")
|
||||
}
|
||||
|
||||
@Test fun `an unreachable server is reported, not thrown`() {
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
assertThat(AppPassword.revoke(httpClient, server.url("/remote.php/dav/"))).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class CalDavDiscoveryTest {
|
||||
|
||||
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
|
||||
private val server = MockWebServer()
|
||||
private lateinit var discovery: CalDavDiscovery
|
||||
|
||||
@Before fun start() {
|
||||
server.start()
|
||||
discovery = CalDavDiscovery(httpClient)
|
||||
}
|
||||
|
||||
@After fun stop() = server.shutdown()
|
||||
|
||||
private fun options(dav: String = "1, 2, 3, calendar-access") =
|
||||
MockResponse().setResponseCode(200).setHeader("DAV", dav)
|
||||
|
||||
private fun multistatus(body: String) = MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody(body)
|
||||
|
||||
private fun principalResponse(href: String?) = multistatus(
|
||||
"""
|
||||
<multistatus xmlns="DAV:">
|
||||
<response>
|
||||
<href>/dav/</href>
|
||||
<propstat><prop><current-user-principal>
|
||||
${href?.let { "<href>$it</href>" } ?: "<unauthenticated/>"}
|
||||
</current-user-principal></prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
</multistatus>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
private fun homeSetResponse(vararg hrefs: String) = multistatus(
|
||||
"""
|
||||
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav">
|
||||
<response>
|
||||
<href>/principals/me/</href>
|
||||
<propstat><prop><CAL:calendar-home-set>
|
||||
${hrefs.joinToString("\n") { "<href>$it</href>" }}
|
||||
</CAL:calendar-home-set></prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
</multistatus>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
/** One `<response>` for a collection in a Depth-1 listing. */
|
||||
private fun collection(
|
||||
href: String,
|
||||
resourceTypes: String,
|
||||
componentSet: String? = null,
|
||||
displayName: String = "Tasks",
|
||||
extraProps: String = "",
|
||||
) = """
|
||||
<response>
|
||||
<href>$href</href>
|
||||
<propstat><prop>
|
||||
<resourcetype>$resourceTypes</resourcetype>
|
||||
<displayname>$displayName</displayname>
|
||||
${componentSet ?: ""}
|
||||
$extraProps
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
"""
|
||||
|
||||
private fun listing(vararg responses: String) = multistatus(
|
||||
"""
|
||||
<multistatus xmlns="DAV:" xmlns:CAL="urn:ietf:params:xml:ns:caldav"
|
||||
xmlns:CS="http://calendarserver.org/ns/"
|
||||
xmlns:nc="http://nextcloud.com/ns">
|
||||
<response><href>/dav/calendars/me/</href>
|
||||
<propstat><prop><resourcetype><collection/></resourcetype></prop>
|
||||
<status>HTTP/1.1 200 OK</status></propstat></response>
|
||||
${responses.joinToString("\n")}
|
||||
</multistatus>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
private fun discoverCollections(listingBody: MockResponse): List<TaskCollection> {
|
||||
server.enqueue(homeSetResponse("/dav/calendars/me/"))
|
||||
server.enqueue(listingBody)
|
||||
val outcome = discovery.fromPrincipal(server.url("/principals/me/"))
|
||||
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Found::class.java)
|
||||
return (outcome as CalDavDiscovery.Outcome.Found).collections
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ principal
|
||||
|
||||
@Test
|
||||
fun `a home set is resolved against where the principal actually landed`() {
|
||||
// ⚠️ followRedirects rewrites the resource's location in place, and a
|
||||
// relative home-set href means "relative to the answer". Resolved
|
||||
// against the URL we aimed at, a permanently moved principal names a
|
||||
// path under the old prefix: the home-set PROPFIND 404s and a perfectly
|
||||
// good account reports that it holds no calendars.
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(301)
|
||||
.setHeader("Location", server.url("/nc/principals/me/").toString()),
|
||||
)
|
||||
server.enqueue(homeSetResponse("calendars/me/"))
|
||||
server.enqueue(listing(collection("/nc/principals/me/calendars/me/tasks/", "<collection/>")))
|
||||
|
||||
val outcome = discovery.fromPrincipal(server.url("/principals/me/"))
|
||||
|
||||
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Found::class.java)
|
||||
// Under the prefix the redirect landed on, not the one we aimed at —
|
||||
// which would have been /principals/me/calendars/me/, a 404.
|
||||
assertThat((outcome as CalDavDiscovery.Outcome.Found).homeSets.single().encodedPath)
|
||||
.isEqualTo("/nc/principals/me/calendars/me/")
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun `a 200 carrying unauthenticated is a failed login, not an empty result`() {
|
||||
// RFC 5397 section 3. Without this check a rejected credential looks like
|
||||
// a successful discovery that happened to find nothing — which is the
|
||||
// shape of bug report nobody can act on.
|
||||
server.enqueue(options())
|
||||
server.enqueue(principalResponse(null))
|
||||
|
||||
assertThat(discovery.probe(server.url("/dav/")))
|
||||
.isEqualTo(CalDavDiscovery.Outcome.Unauthenticated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 means sign in, not failure`() {
|
||||
// iCloud and Zoho answer 401 from /.well-known/caldav — the endpoint *is*
|
||||
// the DAV root and wants auth. RFC-legal, and must not end the walk.
|
||||
server.enqueue(options())
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
assertThat(discovery.probe(server.url("/dav/")))
|
||||
.isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a WebDAV server without calendar-access is not a CalDAV server`() {
|
||||
server.enqueue(options(dav = "1, 2, 3"))
|
||||
server.enqueue(principalResponse("/principals/me/"))
|
||||
|
||||
val outcome = discovery.probe(server.url("/dav/"))
|
||||
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.NotCalDav::class.java)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ home sets
|
||||
|
||||
@Test
|
||||
fun `every calendar-home-set href is followed, not just the first`() {
|
||||
// Multiple home sets are normative (RFC 4791 section 6.2.1's own example)
|
||||
// and iCloud depends on it.
|
||||
server.enqueue(homeSetResponse("/dav/one/", "/dav/two/"))
|
||||
server.enqueue(listing(collection("/dav/one/tasks/", "<collection/><CAL:calendar/>")))
|
||||
server.enqueue(listing(collection("/dav/two/more/", "<collection/><CAL:calendar/>")))
|
||||
|
||||
val outcome = discovery.fromPrincipal(server.url("/principals/me/"))
|
||||
val found = outcome as CalDavDiscovery.Outcome.Found
|
||||
assertThat(found.collections.map { it.url.encodedPath })
|
||||
.containsExactly("/dav/one/tasks/", "/dav/two/more/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 401 on one home set does not invalidate the whole account`() {
|
||||
// The iCloud shape: principal on one host, home set on another. The
|
||||
// interceptor withholds the credential from the second host by design, so
|
||||
// a 401 there must not tell the user to sign in again with credentials
|
||||
// that just worked.
|
||||
server.enqueue(homeSetResponse("/dav/one/", "/dav/two/"))
|
||||
server.enqueue(listing(collection("/dav/one/tasks/", "<collection/><CAL:calendar/>")))
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
val found = discovery.fromPrincipal(server.url("/principals/me/")) as CalDavDiscovery.Outcome.Found
|
||||
assertThat(found.collections.map { it.url.encodedPath }).containsExactly("/dav/one/tasks/")
|
||||
assertThat(found.failedHomeSets).hasSize(1)
|
||||
assertThat(found.failedHomeSets.single().needsAuthentication).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every home set failing is a server error, not an empty account`() {
|
||||
// Returning Found with no collections says "connected, no task lists" for
|
||||
// what is actually a 500 — a bug report nobody can act on.
|
||||
server.enqueue(homeSetResponse("/dav/one/"))
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
assertThat(discovery.fromPrincipal(server.url("/principals/me/")))
|
||||
.isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when every home set wants credentials, it names the hosts to allow`() {
|
||||
server.enqueue(homeSetResponse("/dav/one/"))
|
||||
server.enqueue(MockResponse().setResponseCode(401))
|
||||
|
||||
val outcome = discovery.fromPrincipal(server.url("/principals/me/"))
|
||||
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.NeedsAuthentication::class.java)
|
||||
// Without the host, the caller can never widen the credential allowlist —
|
||||
// the cross-host home set stays permanently unreachable.
|
||||
assertThat((outcome as CalDavDiscovery.Outcome.NeedsAuthentication).hosts)
|
||||
.containsExactly(server.hostName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a typed http URL is refused with the scheme as the reason`() {
|
||||
// Credentials are never sent over cleartext, so this could only ever
|
||||
// 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)
|
||||
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
|
||||
fun `a colour comes back as packed ARGB, not as a decimal string`() {
|
||||
val collections = discoverCollections(
|
||||
listing(
|
||||
collection(
|
||||
"/dav/calendars/me/tasks/",
|
||||
"<collection/><CAL:calendar/>",
|
||||
extraProps = """<x1:calendar-color xmlns:x1="http://apple.com/ns/ical/">#FF0000FF</x1:calendar-color>""",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertThat(collections.single().color).isEqualTo(0xFFFF0000.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a share is flagged, because Nextcloud rewrites bodies fetched from one`() {
|
||||
val collections = discoverCollections(
|
||||
listing(
|
||||
collection("/dav/calendars/me/shared/", "<collection/><CAL:calendar/><CS:shared/>"),
|
||||
),
|
||||
)
|
||||
assertThat(collections.single().isShared).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a principal with no home set is a failure worth naming`() {
|
||||
server.enqueue(homeSetResponse())
|
||||
val outcome = discovery.fromPrincipal(server.url("/principals/me/"))
|
||||
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java)
|
||||
}
|
||||
|
||||
// --------------------------------------------------- the two filters
|
||||
|
||||
@Test
|
||||
fun `an absent supported-calendar-component-set means supports everything`() {
|
||||
// RFC 4791 section 5.2.3 says it SHOULD NOT come back from allprop, so it
|
||||
// is legitimately missing. Requiring VTODO to be listed silently drops
|
||||
// every server that does not advertise it.
|
||||
val collections = discoverCollections(
|
||||
listing(collection("/dav/calendars/me/tasks/", "<collection/><CAL:calendar/>")),
|
||||
)
|
||||
assertThat(collections.map { it.url.encodedPath }).containsExactly("/dav/calendars/me/tasks/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty component set is treated as everything, not as nothing`() {
|
||||
// The grammar is (comp+) so this is non-conformant, and dav4jvm's parser
|
||||
// starts all-false — arriving indistinguishable from "no VTODO".
|
||||
val collections = discoverCollections(
|
||||
listing(
|
||||
collection(
|
||||
"/dav/calendars/me/tasks/",
|
||||
"<collection/><CAL:calendar/>",
|
||||
componentSet = "<CAL:supported-calendar-component-set/>",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertThat(collections).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a VEVENT-only collection is excluded`() {
|
||||
val collections = discoverCollections(
|
||||
listing(
|
||||
collection(
|
||||
"/dav/calendars/me/events/",
|
||||
"<collection/><CAL:calendar/>",
|
||||
componentSet = """<CAL:supported-calendar-component-set><CAL:comp name="VEVENT"/></CAL:supported-calendar-component-set>""",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertThat(collections).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SOGo's personal calendar survives, because the test is positive not exclusionary`() {
|
||||
// SOGo reports collection + calendar + schedule-outbox simultaneously for
|
||||
// every non-Apple client, i.e. for us. Excluding schedule-outbox — the
|
||||
// obvious rule — would drop the user's main calendar.
|
||||
val collections = discoverCollections(
|
||||
listing(
|
||||
collection(
|
||||
"/SOGo/dav/me/Calendar/personal/",
|
||||
"<collection/><CAL:calendar/><CAL:schedule-outbox/>",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertThat(collections).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shared calendar is kept`() {
|
||||
val collections = discoverCollections(
|
||||
listing(
|
||||
collection("/dav/calendars/me/shared/", "<collection/><CAL:calendar/><CS:shared/>"),
|
||||
),
|
||||
)
|
||||
assertThat(collections).hasSize(1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Nextcloud's trashed calendar is dropped, because it strips caldav-calendar`() {
|
||||
val collections = discoverCollections(
|
||||
listing(
|
||||
collection("/dav/calendars/me/deleted/", "<collection/><nc:deleted-calendar/>"),
|
||||
),
|
||||
)
|
||||
assertThat(collections).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inboxes, outboxes and notification collections are not task lists`() {
|
||||
val collections = discoverCollections(
|
||||
listing(
|
||||
collection("/dav/calendars/me/inbox/", "<collection/><CAL:schedule-inbox/>"),
|
||||
collection("/dav/calendars/me/outbox/", "<collection/><CAL:schedule-outbox/>"),
|
||||
collection("/dav/calendars/me/notifications/", "<collection/><CS:notification/>"),
|
||||
collection("/dav/calendars/me/tasks/", "<collection/><CAL:calendar/>"),
|
||||
),
|
||||
)
|
||||
assertThat(collections.map { it.url.encodedPath }).containsExactly("/dav/calendars/me/tasks/")
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- privileges
|
||||
|
||||
@Test
|
||||
fun `an absent privilege set means writable`() {
|
||||
// RFC 3744 section 3.7 lets a server withhold it, and assuming read-only
|
||||
// hides collections the user can perfectly well write to.
|
||||
val collections = discoverCollections(
|
||||
listing(collection("/dav/calendars/me/tasks/", "<collection/><CAL:calendar/>")),
|
||||
)
|
||||
assertThat(collections.single().readOnly).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a read-only share is reported as read-only`() {
|
||||
val collections = discoverCollections(
|
||||
listing(
|
||||
collection(
|
||||
"/dav/calendars/me/shared/",
|
||||
"<collection/><CAL:calendar/><CS:shared/>",
|
||||
extraProps = "<current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertThat(collections.single().readOnly).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync-collection support is reported when advertised`() {
|
||||
val collections = discoverCollections(
|
||||
listing(
|
||||
collection(
|
||||
"/dav/calendars/me/tasks/",
|
||||
"<collection/><CAL:calendar/>",
|
||||
extraProps = "<supported-report-set><supported-report><report><sync-collection/></report></supported-report></supported-report-set>",
|
||||
),
|
||||
),
|
||||
)
|
||||
assertThat(collections.single().supportsSyncCollection).isTrue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import at.bitfire.dav4jvm.BasicDigestAuthHandler
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.junit.Test
|
||||
|
||||
class CalDavHttpTest {
|
||||
|
||||
private val origin = "https://cloud.example.com/remote.php/dav/".toHttpUrl()
|
||||
|
||||
@Test
|
||||
fun `the auth handler is scoped to the registrable domain, not the host`() {
|
||||
// ⚠️ The handler derives the registrable domain of each request host and
|
||||
// compares. Passing the full host means the comparison is
|
||||
// "cloud.example.com" == "example.com" — never true — and the credential
|
||||
// is withheld from every single request. That is every self-hosted
|
||||
// Nextcloud, silently answering 401 forever.
|
||||
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
assertThat(handler.domain).isEqualTo("example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `so the credential actually reaches the host it was made for`() {
|
||||
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
|
||||
val authorised = handler.authenticateRequest(
|
||||
Request.Builder().url("https://cloud.example.com/remote.php/dav/").build(),
|
||||
null,
|
||||
)
|
||||
assertThat(authorised?.header("Authorization")).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `and reaches a sibling host in the same domain, which is what iCloud needs`() {
|
||||
// The principal is on caldav.icloud.com and the home set on
|
||||
// pNN-caldav.icloud.com. An exact-host allowlist refuses the second.
|
||||
val client = CalDavHttp.authenticated(
|
||||
"Agendula",
|
||||
"user",
|
||||
"pw",
|
||||
"https://caldav.icloud.com/".toHttpUrl(),
|
||||
)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
|
||||
val authorised = handler.authenticateRequest(
|
||||
Request.Builder().url("https://p42-caldav.icloud.com/1234/calendars/").build(),
|
||||
null,
|
||||
)
|
||||
assertThat(authorised?.header("Authorization")).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a multi-label public suffix is not mistaken for a domain`() {
|
||||
// ⚠️ A last-two-labels split scopes this to "co.uk" and then offers the
|
||||
// password preemptively to any host under it — one an attacker can buy a
|
||||
// certificate for. This is the headline case.
|
||||
val client = CalDavHttp.authenticated(
|
||||
"Agendula",
|
||||
"user",
|
||||
"pw",
|
||||
"https://cloud.example.co.uk/dav/".toHttpUrl(),
|
||||
)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
assertThat(handler.domain).isEqualTo("example.co.uk")
|
||||
|
||||
val stranger = handler.authenticateRequest(
|
||||
Request.Builder().url("https://attacker.co.uk/dav/").build(),
|
||||
null,
|
||||
)
|
||||
assertThat(stranger).isNull()
|
||||
|
||||
val ours = handler.authenticateRequest(
|
||||
Request.Builder().url("https://cloud.example.co.uk/dav/").build(),
|
||||
null,
|
||||
)
|
||||
assertThat(ours?.header("Authorization")).isNotNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a free-subdomain host does not trust its neighbours`() {
|
||||
// The PSL private section covers the dynamic-DNS providers self-hosters
|
||||
// actually use, where the neighbour is a stranger with a free account.
|
||||
val client = CalDavHttp.authenticated(
|
||||
"Agendula",
|
||||
"user",
|
||||
"pw",
|
||||
"https://myhome.duckdns.org/dav/".toHttpUrl(),
|
||||
)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
assertThat(handler.domain).isEqualTo("myhome.duckdns.org")
|
||||
|
||||
val neighbour = handler.authenticateRequest(
|
||||
Request.Builder().url("https://evil.duckdns.org/dav/").build(),
|
||||
null,
|
||||
)
|
||||
assertThat(neighbour).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bare address is scoped to itself, not to its last two labels`() {
|
||||
// ⚠️ topPrivateDomain() is null here, and null means *no restriction* to
|
||||
// the handler — so the fallback has to be the exact host. A split would
|
||||
// scope 192.168.1.10 to "1.10".
|
||||
val client = CalDavHttp.authenticated(
|
||||
"Agendula",
|
||||
"user",
|
||||
"pw",
|
||||
"https://192.168.1.10/dav/".toHttpUrl(),
|
||||
)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
assertThat(handler.domain).isEqualTo("192.168.1.10")
|
||||
|
||||
val ours = handler.authenticateRequest(
|
||||
Request.Builder().url("https://192.168.1.10/dav/").build(),
|
||||
null,
|
||||
)
|
||||
assertThat(ours?.header("Authorization")).isNotNull()
|
||||
|
||||
val other = handler.authenticateRequest(
|
||||
Request.Builder().url("https://10.20.1.10/dav/").build(),
|
||||
null,
|
||||
)
|
||||
assertThat(other).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a single-label host is scoped to itself`() {
|
||||
val client = CalDavHttp.authenticated(
|
||||
"Agendula",
|
||||
"user",
|
||||
"pw",
|
||||
"https://localhost:8443/dav/".toHttpUrl(),
|
||||
)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
assertThat(handler.domain).isEqualTo("localhost")
|
||||
|
||||
val elsewhere = handler.authenticateRequest(
|
||||
Request.Builder().url("https://notlocalhost/dav/").build(),
|
||||
null,
|
||||
)
|
||||
assertThat(elsewhere).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrelated domain gets nothing`() {
|
||||
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
|
||||
assertThat(
|
||||
handler.authenticateRequest(
|
||||
Request.Builder().url("https://evil.example.org/dav/").build(),
|
||||
null,
|
||||
),
|
||||
).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cleartext never carries a preemptive credential`() {
|
||||
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
|
||||
assertThat(
|
||||
handler.authenticateRequest(
|
||||
Request.Builder().url("http://cloud.example.com/dav/").build(),
|
||||
null,
|
||||
)?.header("Authorization"),
|
||||
).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cleartext carries no credential even when the server asks for one`() {
|
||||
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
val request = Request.Builder().url("http://cloud.example.com/dav/").build()
|
||||
|
||||
// ⚠️ Gating only the preemptive path leaves this open: the server just
|
||||
// has to ask. Basic is the password, in a header every hop can read.
|
||||
assertThat(handler.authenticateRequest(request, challenge(request, "Basic"))).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a challenge over TLS is still answered`() {
|
||||
val client = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
|
||||
val handler = client.networkInterceptors.filterIsInstance<BasicDigestAuthHandler>().single()
|
||||
val request = Request.Builder().url("https://cloud.example.com/dav/").build()
|
||||
|
||||
val authorised = handler.authenticateRequest(request, challenge(request, "Basic"))
|
||||
assertThat(authorised?.header("Authorization")).startsWith("Basic")
|
||||
}
|
||||
|
||||
private fun challenge(request: Request, scheme: String) = Response.Builder()
|
||||
.request(request)
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(401)
|
||||
.message("Authentication required")
|
||||
.header("WWW-Authenticate", "$scheme realm=\"dav\"")
|
||||
.build()
|
||||
|
||||
@Test
|
||||
fun `the shared client bounds a whole call, not just each read`() {
|
||||
// ⚠️ readTimeout is per-read, so a server trickling a byte every 119
|
||||
// seconds satisfies it for ever and holds a sequential sync for the
|
||||
// entire WorkManager window. The ceiling is wide enough for a multiget
|
||||
// of a full batch over a slow homelab link and far below that window;
|
||||
// the revocation, which must return in seconds, still sets its own.
|
||||
assertThat(CalDavHttp.anonymous("Agendula").callTimeoutMillis)
|
||||
.isEqualTo(3 * 60 * 1000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `derived clients share one connection pool`() {
|
||||
// A fresh OkHttpClient per probe gives each its own pool and dispatcher
|
||||
// threads: every rung of the discovery ladder reopens TLS, and the
|
||||
// abandoned clients linger until GC.
|
||||
val a = CalDavHttp.anonymous("Agendula")
|
||||
val b = CalDavHttp.authenticated("Agendula", "user", "pw", origin)
|
||||
assertThat(a.connectionPool).isSameInstanceAs(b.connectionPool)
|
||||
assertThat(a.dispatcher).isSameInstanceAs(b.dispatcher)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `redirects are never followed automatically`() {
|
||||
// DavResource requires it and asserts on it: redirects are followed by
|
||||
// hand so a HTTPS-to-HTTP downgrade can be refused and a permanent move
|
||||
// reported.
|
||||
assertThat(CalDavHttp.anonymous("Agendula").followRedirects).isFalse()
|
||||
assertThat(CalDavHttp.authenticated("Agendula", "u", "p", origin).followRedirects).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/** RFC 6578. Every case here is a documented failure of a real client. */
|
||||
class CalendarChangesTest {
|
||||
|
||||
private val server = MockWebServer()
|
||||
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
|
||||
private lateinit var collection: CalendarCollection
|
||||
|
||||
@Before fun start() {
|
||||
server.start()
|
||||
collection = CalendarCollection(httpClient, server.url("/dav/tasks/"))
|
||||
}
|
||||
|
||||
@After fun stop() = server.shutdown()
|
||||
|
||||
@Test fun `changed and removed are told apart`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/kept.ics</href>
|
||||
<propstat><prop><getetag>"e1"</getetag></prop>
|
||||
<status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav/tasks/gone.ics</href>
|
||||
<status>HTTP/1.1 404 Not Found</status>
|
||||
</response>
|
||||
<sync-token>urn:x:2</sync-token>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val page = collection.changes("urn:x:1") as ChangeSet.Page
|
||||
|
||||
// ⚠️ The deletion status is at DAV:response level, not inside a propstat.
|
||||
assertThat(page.changed.map { it.href.encodedPath })
|
||||
.containsExactly("/dav/tasks/kept.ics")
|
||||
assertThat(page.removed.map { it.encodedPath })
|
||||
.containsExactly("/dav/tasks/gone.ics")
|
||||
assertThat(page.token).isEqualTo("urn:x:2")
|
||||
assertThat(page.truncated).isFalse()
|
||||
}
|
||||
|
||||
@Test fun `a status that is neither success nor removal is carried as a change`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/refused.ics</href>
|
||||
<status>HTTP/1.1 403 Forbidden</status>
|
||||
</response>
|
||||
<sync-token>urn:x:2</sync-token>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val page = collection.changes("urn:x:1") as ChangeSet.Page
|
||||
|
||||
// Dropping it would report the resource as unchanged and leave the row
|
||||
// stale until the next full reconciliation. With no validator on it the
|
||||
// multiget is forced, and that is what grades the refusal.
|
||||
assertThat(page.changed.map { it.href.encodedPath })
|
||||
.containsExactly("/dav/tasks/refused.ics")
|
||||
assertThat(page.changed.single().eTag).isNull()
|
||||
assertThat(page.removed).isEmpty()
|
||||
}
|
||||
|
||||
@Test fun `no DAV limit is ever sent`() {
|
||||
server.enqueue(multistatus("<sync-token>urn:x:2</sync-token>"))
|
||||
collection.changes(null)
|
||||
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
// Nextcloud regressed DAV:limit to a localised HTML error page, so asking
|
||||
// for a bounded page is how you get an unparseable response.
|
||||
assertThat(body).doesNotContain("limit")
|
||||
assertThat(body).contains("sync-level")
|
||||
}
|
||||
|
||||
@Test fun `a 507 on our own href is truncation, not failure`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/one.ics</href>
|
||||
<propstat><prop><getetag>"e1"</getetag></prop>
|
||||
<status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav/tasks/</href>
|
||||
<status>HTTP/1.1 507 Insufficient Storage</status>
|
||||
</response>
|
||||
<sync-token>urn:x:2</sync-token>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val page = collection.changes("urn:x:1") as ChangeSet.Page
|
||||
|
||||
assertThat(page.truncated).isTrue()
|
||||
assertThat(page.changed).hasSize(1)
|
||||
assertThat(page.token).isEqualTo("urn:x:2")
|
||||
}
|
||||
|
||||
@Test fun `a 207 with no sync-token is not an error`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/one.ics</href>
|
||||
<propstat><prop><getetag>"e1"</getetag></prop>
|
||||
<status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val page = collection.changes("urn:x:1") as ChangeSet.Page
|
||||
|
||||
// The changes are real; only the cursor is missing.
|
||||
assertThat(page.changed).hasSize(1)
|
||||
assertThat(page.token).isNull()
|
||||
}
|
||||
|
||||
// --------------------------------------------------- token invalidation
|
||||
|
||||
@Test fun `invalidation is recognised on every status servers use for it`() {
|
||||
// ⚠️ 403 sabre, 400 Google, 409 Radicale, 412 Evolution. Thunderbird
|
||||
// matches 400 alone and never recovers from the 403 most self-hosted
|
||||
// servers emit.
|
||||
listOf(400, 403, 409, 412).forEach { code ->
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(code)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody(
|
||||
"<D:error xmlns:D=\"DAV:\"><D:valid-sync-token/></D:error>",
|
||||
),
|
||||
)
|
||||
assertThat(collection.changes("stale")).isEqualTo(ChangeSet.TokenInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `invalidation is recognised whatever prefix the server uses`() {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(403)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<error xmlns=\"DAV:\"><valid-sync-token/></error>"),
|
||||
)
|
||||
assertThat(collection.changes("stale")).isEqualTo(ChangeSet.TokenInvalid)
|
||||
}
|
||||
|
||||
@Test fun `a 4xx without the marker is not an invalidation`() {
|
||||
server.enqueue(MockResponse().setResponseCode(403).setBody("nope"))
|
||||
// Treating every 403 as invalidation would discard a working token on a
|
||||
// permissions error and re-download the whole collection.
|
||||
assertThat(collection.changes("good")).isInstanceOf(ChangeSet.Failed::class.java)
|
||||
}
|
||||
|
||||
@Test fun `an unimplemented report is reported as unsupported`() {
|
||||
server.enqueue(MockResponse().setResponseCode(501))
|
||||
assertThat(collection.changes(null)).isEqualTo(ChangeSet.Unsupported)
|
||||
}
|
||||
|
||||
@Test fun `a 507 outer status is a failure rather than truncation`() {
|
||||
server.enqueue(MockResponse().setResponseCode(507))
|
||||
assertThat(collection.changes("t")).isInstanceOf(ChangeSet.Failed::class.java)
|
||||
}
|
||||
|
||||
private fun multistatus(body: String) = MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns=\"DAV:\">$body</multistatus>")
|
||||
}
|
||||
@@ -0,0 +1,648 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.buffer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class CalendarCollectionTest {
|
||||
|
||||
private val server = MockWebServer()
|
||||
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
|
||||
private lateinit var collection: CalendarCollection
|
||||
|
||||
@Before fun start() {
|
||||
server.start()
|
||||
collection = CalendarCollection(httpClient, server.url("/dav/tasks/"))
|
||||
}
|
||||
|
||||
@After fun stop() = server.shutdown()
|
||||
|
||||
// ------------------------------------------------------------------ list
|
||||
|
||||
@Test fun `list reads hrefs and strong etags`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/one.ics</href>
|
||||
<propstat><prop><getetag>"e1"</getetag></prop>
|
||||
<status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav/tasks/two.ics</href>
|
||||
<propstat><prop><getetag>W/"e2"</getetag></prop>
|
||||
<status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val refs = collection.list().getOrThrow()
|
||||
|
||||
assertThat(refs.map { it.href.encodedPath })
|
||||
.containsExactly("/dav/tasks/one.ics", "/dav/tasks/two.ics")
|
||||
assertThat(refs[0].eTag).isEqualTo(ETag("e1", weak = false))
|
||||
assertThat(refs[1].eTag!!.usable).isFalse()
|
||||
}
|
||||
|
||||
@Test fun `list asks for no calendar-data and no time-range`() {
|
||||
server.enqueue(multistatus(""))
|
||||
collection.list().getOrThrow()
|
||||
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
// Bodies in a listing would download the whole collection every time, and
|
||||
// would arrive without an ETag matched to them.
|
||||
assertThat(body).doesNotContain("calendar-data")
|
||||
// A VTODO without DTSTART or DUE is outside every range by construction,
|
||||
// and some servers answer a ranged VTODO filter with nothing at all.
|
||||
assertThat(body).doesNotContain("time-range")
|
||||
assertThat(body).contains("VTODO")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- fetch
|
||||
|
||||
@Test fun `fetch matches responses against the request`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/one.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e1"</getetag>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav/tasks/somebody-elses.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e9"</getetag>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val result = collection.fetch(listOf(href("one.ics"), href("two.ics"))).getOrThrow()
|
||||
|
||||
assertThat(result.resources.map { it.href.encodedPath })
|
||||
.containsExactly("/dav/tasks/one.ics")
|
||||
// Real servers answer with URLs nobody asked about. Applying one of those
|
||||
// to a row keyed by another href corrupts the wrong task.
|
||||
assertThat(result.unsolicited.map { it.encodedPath })
|
||||
.containsExactly("/dav/tasks/somebody-elses.ics")
|
||||
assertThat(result.missing.map { it.encodedPath }).containsExactly("/dav/tasks/two.ics")
|
||||
}
|
||||
|
||||
@Test fun `a server that respells the path is still matched`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/my%40dav.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e1"</getetag>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val asked = href("my@dav.ics")
|
||||
val result = collection.fetch(listOf(asked)).getOrThrow()
|
||||
|
||||
// ⚠️ Compared raw, one resource lands in unsolicited *and* missing, its
|
||||
// body is dropped, and three such runs quarantine it out of the download
|
||||
// for good — nothing on that side can refund the count.
|
||||
assertThat(result.resources).hasSize(1)
|
||||
assertThat(result.missing).isEmpty()
|
||||
assertThat(result.unsolicited).isEmpty()
|
||||
assertThat(result.failed).isEmpty()
|
||||
// The href we asked for, not the server's spelling: apply is keyed on
|
||||
// the local row's href.
|
||||
assertThat(result.resources.single().href).isEqualTo(asked)
|
||||
}
|
||||
|
||||
@Test fun `an href stored before a redirect still matches the reply`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/one.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e1"</getetag>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
// ⚠️ The REPORT body carries only paths, so the reply resolves against
|
||||
// the collection we asked — while a stored href can still carry the
|
||||
// origin we held before a redirect moved us. Keying on the origin would
|
||||
// put every resource of a redirected collection into both buckets.
|
||||
val stored = "https://old.example.com/dav/tasks/one.ics".toHttpUrl()
|
||||
val result = collection.fetch(listOf(stored)).getOrThrow()
|
||||
|
||||
assertThat(result.resources.map { it.href }).containsExactly(stored)
|
||||
assertThat(result.missing).isEmpty()
|
||||
assertThat(result.unsolicited).isEmpty()
|
||||
}
|
||||
|
||||
@Test fun `the same path on another host is not our resource`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>https://evil.example.com/dav/tasks/one.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e1"</getetag>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
|
||||
|
||||
// Matching on the path alone would apply one host's body to another
|
||||
// host's row.
|
||||
assertThat(result.resources).isEmpty()
|
||||
assertThat(result.unsolicited.map { it.host }).containsExactly("evil.example.com")
|
||||
assertThat(result.missing).hasSize(1)
|
||||
}
|
||||
|
||||
@Test fun `an ambiguous spelling matches nothing rather than the wrong row`() {
|
||||
// A third spelling: matches neither request exactly, decodes like both.
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/%6Dy@dav.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e1"</getetag>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val plain = href("my@dav.ics")
|
||||
val encoded = server.url("/dav/tasks/my%40dav.ics")
|
||||
val result = collection.fetch(listOf(plain, encoded)).getOrThrow()
|
||||
|
||||
// ⚠️ Two rows decode alike, so no answer can say which it means. Guessing
|
||||
// applies one row's body to the other; both are reported instead, and the
|
||||
// syncer will not spend a quarantine count on a stray of the same path.
|
||||
assertThat(result.resources).isEmpty()
|
||||
assertThat(result.unsolicited).hasSize(1)
|
||||
assertThat(result.missing).containsExactly(plain, encoded)
|
||||
}
|
||||
|
||||
@Test fun `a trailing slash is still a different resource`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/one.ics/</href>
|
||||
<propstat><prop><getetag>"e1"</getetag></prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
|
||||
|
||||
// Deliberately not normalised, as UrlUtils.equals also refuses to.
|
||||
assertThat(result.resources).isEmpty()
|
||||
assertThat(result.missing).hasSize(1)
|
||||
}
|
||||
|
||||
@Test fun `an encoded slash is not the same as a real one`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/a/b.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e1"</getetag>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val asked = server.url("/dav/tasks/a%2Fb.ics")
|
||||
val result = collection.fetch(listOf(asked)).getOrThrow()
|
||||
|
||||
// One segment "a/b" is not two segments "a" and "b" — which is why the
|
||||
// key stays a list rather than a joined string.
|
||||
assertThat(result.resources).isEmpty()
|
||||
assertThat(result.missing).containsExactly(asked)
|
||||
}
|
||||
|
||||
@Test fun `a refused resource is reported as failed, not as missing`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/one.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e1"</getetag>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav/tasks/two.ics</href>
|
||||
<status>HTTP/1.1 403 Forbidden</status>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val result = collection.fetch(listOf(href("one.ics"), href("two.ics"))).getOrThrow()
|
||||
|
||||
// The server did mention two.ics, so it is not missing. Merging the two
|
||||
// costs the caller the difference between "refused" and "omitted", which
|
||||
// are not the same fact and do not deserve the same answer.
|
||||
assertThat(result.missing).isEmpty()
|
||||
assertThat(result.failed.map { it.href.encodedPath to it.code })
|
||||
.containsExactly("/dav/tasks/two.ics" to 403)
|
||||
// One bad member does not cost the batch its good ones.
|
||||
assertThat(result.resources.map { it.href.encodedPath })
|
||||
.containsExactly("/dav/tasks/one.ics")
|
||||
}
|
||||
|
||||
@Test fun `a success carrying no calendar-data is reported as failed`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/one.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e1"</getetag>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
|
||||
|
||||
// No HTTP judgement to report: the server said yes and sent nothing.
|
||||
assertThat(result.failed.map { it.href.encodedPath to it.code })
|
||||
.containsExactly("/dav/tasks/one.ics" to 0)
|
||||
assertThat(result.resources).isEmpty()
|
||||
assertThat(result.missing).isEmpty()
|
||||
}
|
||||
|
||||
@Test fun `a propstat refusal carries its own status, not a blank`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/one.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e1"</getetag>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
<propstat><prop>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav"/>
|
||||
</prop><status>HTTP/1.1 403 Forbidden</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
|
||||
|
||||
// A per-property refusal is the usual shape for "you may not read this
|
||||
// one object". Response.properties drops non-2xx propstats, so reporting
|
||||
// 0 here would make a deterministic refusal look like a transient blank.
|
||||
assertThat(result.failed.map { it.code }).containsExactly(403)
|
||||
}
|
||||
|
||||
@Test fun `a resource the server says is gone carries its status`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/one.ics</href>
|
||||
<status>HTTP/1.1 404 Not Found</status>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val result = collection.fetch(listOf(href("one.ics"))).getOrThrow()
|
||||
|
||||
// The code has to survive the trip: the engine treats 404 and 403 as
|
||||
// opposite verdicts.
|
||||
assertThat(result.failed.map { it.code }).containsExactly(404)
|
||||
assertThat(result.missing).isEmpty()
|
||||
}
|
||||
|
||||
@Test fun `fetch batches`() {
|
||||
repeat(2) { server.enqueue(multistatus("")) }
|
||||
collection.fetch((1..3).map { href("$it.ics") }, batchSize = 2).getOrThrow()
|
||||
assertThat(server.requestCount).isEqualTo(2)
|
||||
}
|
||||
|
||||
@Test fun `a batch that fails keeps the batches that already worked`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/1.ics</href>
|
||||
<propstat><prop>
|
||||
<getetag>"e1"</getetag>
|
||||
<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">BEGIN:VCALENDAR
|
||||
END:VCALENDAR</C:calendar-data>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
val result = collection.fetch((1..3).map { href("$it.ics") }, batchSize = 1).getOrThrow()
|
||||
|
||||
// ⚠️ Wrapping the whole loop threw away everything parsed so far and
|
||||
// answered failure, so the engine recorded a collection failure and
|
||||
// re-downloaded the lot next run — on a flaky link, for ever.
|
||||
assertThat(result.resources.map { it.href.encodedPath })
|
||||
.containsExactly("/dav/tasks/1.ics")
|
||||
// The rest were never asked, which is exactly what missing means; the
|
||||
// engine counts those rather than acting on them as deletions.
|
||||
assertThat(result.missing.map { it.encodedPath })
|
||||
.containsExactly("/dav/tasks/2.ics", "/dav/tasks/3.ics")
|
||||
}
|
||||
|
||||
@Test fun `a fetch that never got anything still fails`() {
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
assertThat(collection.fetch(listOf(href("one.ics"))).isFailure).isTrue()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- create
|
||||
|
||||
@Test fun `create with a strong etag is stored`() {
|
||||
server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"new\""))
|
||||
|
||||
val outcome = collection.create("one.ics", "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")
|
||||
|
||||
assertThat(outcome).isInstanceOf(PutOutcome.Stored::class.java)
|
||||
assertThat((outcome as PutOutcome.Stored).eTag).isEqualTo(ETag("new", weak = false))
|
||||
assertThat(server.takeRequest().getHeader("If-None-Match")).isEqualTo("*")
|
||||
}
|
||||
|
||||
@Test fun `create with a weak etag needs a refetch`() {
|
||||
server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "W/\"new\""))
|
||||
// A weak tag is worse than none: storing it makes every later write look
|
||||
// conditional while silently not being one.
|
||||
assertThat(collection.create("one.ics", "x"))
|
||||
.isInstanceOf(PutOutcome.StoredNeedsRefetch::class.java)
|
||||
}
|
||||
|
||||
@Test fun `create with no etag needs a refetch`() {
|
||||
server.enqueue(MockResponse().setResponseCode(201))
|
||||
assertThat(collection.create("one.ics", "x"))
|
||||
.isInstanceOf(PutOutcome.StoredNeedsRefetch::class.java)
|
||||
}
|
||||
|
||||
@Test fun `create answered 412 means the name is taken`() {
|
||||
server.enqueue(MockResponse().setResponseCode(412))
|
||||
val outcome = collection.create("one.ics", "x")
|
||||
assertThat(outcome).isInstanceOf(PutOutcome.NameTaken::class.java)
|
||||
assertThat((outcome as PutOutcome.NameTaken).href.encodedPath)
|
||||
.isEqualTo("/dav/tasks/one.ics")
|
||||
}
|
||||
|
||||
@Test fun `create answered 415 is rejected and not retried`() {
|
||||
server.enqueue(MockResponse().setResponseCode(415))
|
||||
val outcome = collection.create("one.ics", "x")
|
||||
assertThat(outcome).isInstanceOf(PutOutcome.Rejected::class.java)
|
||||
assertThat((outcome as PutOutcome.Rejected).code).isEqualTo(415)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- update
|
||||
|
||||
@Test fun `update answered 412 with the resource still there is a conflict`() {
|
||||
server.enqueue(MockResponse().setResponseCode(412))
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
assertThat(collection.update(href("one.ics"), "old", "x"))
|
||||
.isEqualTo(PutOutcome.ServerNewer)
|
||||
assertThat(server.takeRequest().getHeader("If-Match")).isEqualTo("\"old\"")
|
||||
assertThat(server.takeRequest().method).isEqualTo("HEAD")
|
||||
}
|
||||
|
||||
@Test fun `update answered 412 with the resource gone is not a conflict`() {
|
||||
server.enqueue(MockResponse().setResponseCode(412))
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
|
||||
// RFC 9110 §13.1.1 requires 412 once the precondition is evaluated, so a
|
||||
// deleted resource and a changed one are the same status code.
|
||||
assertThat(collection.update(href("one.ics"), "old", "x"))
|
||||
.isEqualTo(PutOutcome.Vanished)
|
||||
}
|
||||
|
||||
@Test fun `update without an etag writes unconditionally`() {
|
||||
server.enqueue(MockResponse().setResponseCode(204).setHeader("ETag", "\"new\""))
|
||||
collection.update(href("one.ics"), null, "x")
|
||||
assertThat(server.takeRequest().getHeader("If-Match")).isNull()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- delete
|
||||
|
||||
@Test fun `delete counts 404 and 410 as success`() {
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
assertThat(collection.delete(href("one.ics"), "e")).isEqualTo(DeleteOutcome.Deleted)
|
||||
|
||||
server.enqueue(MockResponse().setResponseCode(410))
|
||||
assertThat(collection.delete(href("one.ics"), "e")).isEqualTo(DeleteOutcome.Deleted)
|
||||
}
|
||||
|
||||
@Test fun `delete sends If-Match and reports a conflict`() {
|
||||
server.enqueue(MockResponse().setResponseCode(412))
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
assertThat(collection.delete(href("one.ics"), "old"))
|
||||
.isEqualTo(DeleteOutcome.ServerNewer)
|
||||
assertThat(server.takeRequest().getHeader("If-Match")).isEqualTo("\"old\"")
|
||||
}
|
||||
|
||||
@Test fun `delete answered 403 is rejected`() {
|
||||
// Nextcloud's trashbin renames a deleted resource, so delete, recreate and
|
||||
// delete again on the same href answers 403.
|
||||
server.enqueue(MockResponse().setResponseCode(403))
|
||||
val outcome = collection.delete(href("one.ics"), null)
|
||||
assertThat(outcome).isInstanceOf(DeleteOutcome.Rejected::class.java)
|
||||
assertThat((outcome as DeleteOutcome.Rejected).code).isEqualTo(403)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- headers
|
||||
|
||||
@Test fun `writes carry Prefer strict and identity encoding`() {
|
||||
val client = CalDavHttp.anonymous("Agendula test")
|
||||
val withHeaders = CalendarCollection(client, server.url("/dav/tasks/"))
|
||||
server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"e\""))
|
||||
|
||||
withHeaders.create("one.ics", "x")
|
||||
|
||||
val request = server.takeRequest()
|
||||
// Without this, sabre runs vobject REPAIR over the body and then withholds
|
||||
// the ETag because the stored bytes are no longer ours.
|
||||
assertThat(request.getHeader("Prefer")).isEqualTo("handling=strict")
|
||||
// Without this, a gzip-compressing proxy weakens every ETag it touches.
|
||||
assertThat(request.getHeader("Accept-Encoding")).isEqualTo("identity")
|
||||
}
|
||||
|
||||
@Test fun `a caller's own Prefer survives beside ours`() {
|
||||
val client = CalDavHttp.anonymous("Agendula test")
|
||||
server.enqueue(MockResponse().setResponseCode(200))
|
||||
|
||||
client.newCall(
|
||||
okhttp3.Request.Builder()
|
||||
.url(server.url("/dav/tasks/one.ics"))
|
||||
.header("Prefer", "return=minimal")
|
||||
.put("x".toRequestBody())
|
||||
.build(),
|
||||
).execute().close()
|
||||
|
||||
// Replacing it would silently drop whatever the caller asked for.
|
||||
assertThat(server.takeRequest().getHeader("Prefer"))
|
||||
.isEqualTo("return=minimal, handling=strict")
|
||||
}
|
||||
|
||||
@Test fun `a body gzipped despite identity is still readable`() {
|
||||
val client = CalDavHttp.anonymous("Agendula test")
|
||||
val body = okio.Buffer()
|
||||
okio.GzipSink(body).buffer().use { it.writeUtf8("BEGIN:VCALENDAR") }
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(200)
|
||||
.setHeader("Content-Encoding", "gzip")
|
||||
.setBody(body),
|
||||
)
|
||||
|
||||
val response = client.newCall(
|
||||
okhttp3.Request.Builder().url(server.url("/dav/tasks/one.ics")).build(),
|
||||
).execute()
|
||||
|
||||
// ⚠️ Asking for identity takes OkHttp's transparent gunzip out of the
|
||||
// loop, so a proxy that compresses anyway hands raw deflate to the
|
||||
// iCalendar parser and a good resource is quarantined as unreadable.
|
||||
assertThat(response.use { it.body!!.string() }).isEqualTo("BEGIN:VCALENDAR")
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- state
|
||||
|
||||
@Test fun `state re-reads read-only and shared`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/</href>
|
||||
<propstat><prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/>
|
||||
<CS:shared xmlns:CS="http://calendarserver.org/ns/"/>
|
||||
</resourcetype>
|
||||
<current-user-privilege-set>
|
||||
<privilege><read/></privilege>
|
||||
</current-user-privilege-set>
|
||||
<CS:getctag xmlns:CS="http://calendarserver.org/ns/">ct-1</CS:getctag>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
val state = collection.state().getOrThrow()
|
||||
|
||||
assertThat(state.collection.readOnly).isTrue()
|
||||
assertThat(state.collection.isShared).isTrue()
|
||||
assertThat(state.ctag).isEqualTo("ct-1")
|
||||
}
|
||||
|
||||
@Test fun `a member reported alongside self never becomes the collection state`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/member.ics</href>
|
||||
<propstat><prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/>
|
||||
</resourcetype>
|
||||
<current-user-privilege-set><privilege><read/></privilege></current-user-privilege-set>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
<response>
|
||||
<href>/dav/tasks/</href>
|
||||
<propstat><prop>
|
||||
<resourcetype>
|
||||
<collection/>
|
||||
<C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/>
|
||||
</resourcetype>
|
||||
<CS:getctag xmlns:CS="http://calendarserver.org/ns/">mine</CS:getctag>
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
// Some servers answer a Depth-0 PROPFIND with a member as well. Taking the
|
||||
// first classifiable response would read that member's ACL as the
|
||||
// collection's own — here, read-only when it is not.
|
||||
val state = collection.state().getOrThrow()
|
||||
assertThat(state.ctag).isEqualTo("mine")
|
||||
assertThat(state.collection.readOnly).isFalse()
|
||||
}
|
||||
|
||||
@Test fun `state fails rather than guessing when the collection is no longer one`() {
|
||||
server.enqueue(
|
||||
multistatus(
|
||||
"""
|
||||
<response>
|
||||
<href>/dav/tasks/</href>
|
||||
<propstat><prop><resourcetype><collection/></resourcetype></prop>
|
||||
<status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
""",
|
||||
),
|
||||
)
|
||||
assertThat(collection.state().isFailure).isTrue()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- setup
|
||||
|
||||
private fun href(name: String): HttpUrl = server.url("/dav/tasks/$name")
|
||||
|
||||
private fun multistatus(responses: String) = MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody("<multistatus xmlns=\"DAV:\">$responses</multistatus>")
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The collection-management side, against a server rather than a mock of one.
|
||||
*
|
||||
* The three things worth pinning are the three that fail silently: which method
|
||||
* gets used, whether the request says `VTODO`, and whether a colour goes out in
|
||||
* a spelling anything can read back.
|
||||
*/
|
||||
class CollectionAdminTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var admin: DavCollectionAdmin
|
||||
|
||||
@Before fun setUp() {
|
||||
server = MockWebServer().apply { start() }
|
||||
admin = DavCollectionAdmin(CalDavHttp.anonymous("test"))
|
||||
}
|
||||
|
||||
@After fun tearDown() = server.shutdown()
|
||||
|
||||
private fun homeSet() = server.url("/dav/calendars/me/")
|
||||
|
||||
@Test
|
||||
fun `MKCALENDAR is read out of Allow and extended MKCOL out of DAV`() {
|
||||
// The normal shape for an extended-MKCOL server: plain MKCOL in Allow,
|
||||
// and the class advertised only in DAV. Reading Allow alone reports
|
||||
// iCloud as unable to create anything at all.
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Allow", "OPTIONS, GET, PROPFIND, MKCOL")
|
||||
.setHeader("DAV", "1, 2, 3, calendar-access, extended-mkcol"),
|
||||
)
|
||||
|
||||
val support = admin.support(homeSet())
|
||||
|
||||
assertThat(support.mkCalendar).isFalse()
|
||||
assertThat(support.extendedMkCol).isTrue()
|
||||
assertThat(support.canCreate).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server offering neither offers nothing`() {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Allow", "OPTIONS, GET, PROPFIND")
|
||||
.setHeader("DAV", "1, 2, calendar-access"),
|
||||
)
|
||||
|
||||
// Posteo disables collection creation and Google has neither. The
|
||||
// affordance has to be gone rather than answering 405 at the end of a
|
||||
// form the user has already filled in.
|
||||
assertThat(admin.support(homeSet()).canCreate).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an OPTIONS that never answers reads as no`() {
|
||||
server.shutdown()
|
||||
|
||||
assertThat(admin.support(homeSet())).isEqualTo(CollectionSupport.NONE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creating asks for VTODO, by MKCALENDAR where it exists`() {
|
||||
server.enqueue(MockResponse().setResponseCode(201))
|
||||
|
||||
val outcome = admin.create(
|
||||
homeSet = homeSet(),
|
||||
name = "shopping",
|
||||
displayName = "Shopping",
|
||||
color = 0xFF4C6FFF.toInt(),
|
||||
support = CollectionSupport(mkCalendar = true, extendedMkCol = true),
|
||||
)
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.method).isEqualTo("MKCALENDAR")
|
||||
// ⚠️ The trailing slash is load-bearing: without it every later
|
||||
// `resolve` against this URL lands in the parent collection.
|
||||
assertThat(request.path).isEqualTo("/dav/calendars/me/shopping/")
|
||||
val body = request.body.readUtf8()
|
||||
// A calendar created without the component set gets the server's
|
||||
// default, which on several is events-only — a task list that refuses
|
||||
// tasks.
|
||||
assertThat(body).contains("VTODO")
|
||||
assertThat(body).contains("Shopping")
|
||||
// #RRGGBBAA, not the packed Int, which prints as "-11702273".
|
||||
assertThat(body).contains("#4C6FFFFF")
|
||||
assertThat(outcome).isInstanceOf(CollectionOutcome.Created::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server with only extended MKCOL gets a resourcetype instead`() {
|
||||
server.enqueue(MockResponse().setResponseCode(201))
|
||||
|
||||
admin.create(
|
||||
homeSet = homeSet(),
|
||||
name = "shopping",
|
||||
displayName = "Shopping",
|
||||
color = null,
|
||||
support = CollectionSupport(mkCalendar = false, extendedMkCol = true),
|
||||
)
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.method).isEqualTo("MKCOL")
|
||||
// The whole difference between the two: here the request says what the
|
||||
// collection is, rather than the method.
|
||||
assertThat(request.body.readUtf8()).contains("resourcetype")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server with neither is refused before a request is made`() {
|
||||
val outcome = admin.create(
|
||||
homeSet = homeSet(),
|
||||
name = "shopping",
|
||||
displayName = "Shopping",
|
||||
color = null,
|
||||
support = CollectionSupport.NONE,
|
||||
)
|
||||
|
||||
assertThat(outcome).isEqualTo(CollectionOutcome.Unsupported)
|
||||
assertThat(server.requestCount).isEqualTo(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refusal carries its status rather than becoming a failure`() {
|
||||
// Nextcloud's trashbin renames a deleted collection instead of removing
|
||||
// it, so re-creating one under a used name answers 403 for ever. The
|
||||
// caller retries under a fresh segment, which it can only do if it can
|
||||
// tell "refused" from "unreachable".
|
||||
server.enqueue(MockResponse().setResponseCode(403))
|
||||
|
||||
val outcome = admin.create(
|
||||
homeSet = homeSet(),
|
||||
name = "shopping",
|
||||
displayName = "Shopping",
|
||||
color = null,
|
||||
support = CollectionSupport(mkCalendar = true, extendedMkCol = false),
|
||||
)
|
||||
|
||||
assertThat((outcome as CollectionOutcome.Refused).code).isEqualTo(403)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rename and a recolour go out as one PROPPATCH`() {
|
||||
server.enqueue(MockResponse().setResponseCode(207).setBody("<d:multistatus xmlns:d=\"DAV:\"/>"))
|
||||
|
||||
val outcome = admin.updateProperties(
|
||||
url = server.url("/dav/calendars/me/shopping/"),
|
||||
displayName = "Groceries",
|
||||
color = 0xFF00FF00.toInt(),
|
||||
)
|
||||
|
||||
val request = server.takeRequest()
|
||||
val body = request.body.readUtf8()
|
||||
assertThat(request.method).isEqualTo("PROPPATCH")
|
||||
// One request, not two: a rename and a recolour are one edit as far as
|
||||
// the user is concerned, and half of one landing is the worse outcome.
|
||||
assertThat(body).contains("Groceries")
|
||||
assertThat(body).contains("#00FF00FF")
|
||||
assertThat(outcome).isEqualTo(CollectionOutcome.Updated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a collection that is already gone counts as deleted`() {
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
|
||||
// The point of a DELETE is for the thing to be absent, and it is.
|
||||
// Treating "already gone" as a failure leaves a row nothing can remove.
|
||||
assertThat(admin.delete(server.url("/dav/calendars/me/shopping/")))
|
||||
.isEqualTo(CollectionOutcome.Updated)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class ETagTest {
|
||||
|
||||
@Test fun `strong tag is unquoted and usable`() {
|
||||
val tag = ETag.parse("\"abc123\"")
|
||||
assertThat(tag).isEqualTo(ETag("abc123", weak = false))
|
||||
assertThat(tag!!.usable).isTrue()
|
||||
}
|
||||
|
||||
@Test fun `weak tag keeps its value but is never usable`() {
|
||||
val tag = ETag.parse("W/\"abc123\"")
|
||||
assertThat(tag).isEqualTo(ETag("abc123", weak = true))
|
||||
// RFC 9110 §13.1.1 — a weak tag cannot be used with If-Match, so anything
|
||||
// that treats this as a validator writes unconditionally without saying so.
|
||||
assertThat(tag!!.usable).isFalse()
|
||||
}
|
||||
|
||||
@Test fun `unquoted tag is accepted`() {
|
||||
assertThat(ETag.parse("abc123")).isEqualTo(ETag("abc123", weak = false))
|
||||
}
|
||||
|
||||
@Test fun `absent and empty are both null`() {
|
||||
assertThat(ETag.parse(null)).isNull()
|
||||
assertThat(ETag.parse("")).isNull()
|
||||
assertThat(ETag.parse(" ")).isNull()
|
||||
assertThat(ETag.parse("\"\"")).isNull()
|
||||
}
|
||||
|
||||
@Test fun `a parsed property is not re-parsed`() {
|
||||
// GetETag has already stripped the marker and recorded it separately, so
|
||||
// running its value back through parse() reports every weak tag as strong.
|
||||
val property = at.bitfire.dav4jvm.property.GetETag("W/\"abc\"")
|
||||
assertThat(ETag.from(property)).isEqualTo(ETag("abc", weak = true))
|
||||
assertThat(ETag.parse(property.eTag)).isEqualTo(ETag("abc", weak = false))
|
||||
}
|
||||
|
||||
@Test fun `a value of W is not a weak marker`() {
|
||||
// "W/" needs something after it; the guard is length, not prefix alone.
|
||||
assertThat(ETag.parse("\"W/\"")).isEqualTo(ETag("W/", weak = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class NextcloudLoginFlowTest {
|
||||
|
||||
private val server = MockWebServer()
|
||||
private lateinit var flow: NextcloudLoginFlow
|
||||
|
||||
@Before fun start() {
|
||||
server.start()
|
||||
flow = NextcloudLoginFlow(OkHttpClient(), userAgent = "Agendula/1.0 (Pixel 8)")
|
||||
}
|
||||
|
||||
@After fun stop() = server.shutdown()
|
||||
|
||||
private fun json(body: String) = MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json; charset=utf-8")
|
||||
.setBody(body)
|
||||
|
||||
private fun startFlow(): NextcloudLoginFlow.Flow {
|
||||
server.enqueue(
|
||||
json(
|
||||
"""
|
||||
{"poll":{"token":"tok-123","endpoint":"${server.url("/index.php/login/v2/poll")}"},
|
||||
"login":"${server.url("/index.php/login/v2/flow/abc")}"}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
return flow.start(server.url("/"), now = 0).getOrThrow()
|
||||
}
|
||||
|
||||
/** As production builds it: DavResource requires no automatic redirects. */
|
||||
private fun noRedirectFlow() = NextcloudLoginFlow(
|
||||
OkHttpClient.Builder().followRedirects(false).build(),
|
||||
userAgent = "Agendula/1.0 (Pixel 8)",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `a redirected init is followed, and stays a POST`() {
|
||||
// ⚠️ Apex to www, a trailing slash, a proxy — Nextcloud canonicalises
|
||||
// with a 301 and the client cannot follow on its own. The method has to
|
||||
// survive: this route answers 405 to a GET.
|
||||
val redirecting = noRedirectFlow()
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(301)
|
||||
.setHeader("Location", server.url("/nc/index.php/login/v2").toString()),
|
||||
)
|
||||
server.enqueue(
|
||||
json(
|
||||
"""
|
||||
{"poll":{"token":"tok-123","endpoint":"${server.url("/nc/index.php/login/v2/poll")}"},
|
||||
"login":"${server.url("/nc/index.php/login/v2/flow/abc")}"}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val started = redirecting.start(server.url("/"), now = 0).getOrThrow()
|
||||
|
||||
assertThat(started.pollToken).isEqualTo("tok-123")
|
||||
server.takeRequest()
|
||||
val followed = server.takeRequest()
|
||||
assertThat(followed.path).isEqualTo("/nc/index.php/login/v2")
|
||||
assertThat(followed.method).isEqualTo("POST")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a redirected poll is followed rather than read as a server error`() {
|
||||
// ⚠️ After approval. Reporting SERVER_ERROR here abandons a password the
|
||||
// server has already minted and can never hand back again.
|
||||
val redirecting = noRedirectFlow()
|
||||
val started = NextcloudLoginFlow.Flow(
|
||||
loginUrl = server.url("/index.php/login/v2/flow/abc"),
|
||||
pollEndpoint = server.url("/index.php/login/v2/poll"),
|
||||
pollToken = "tok-123",
|
||||
deadlineEpochSeconds = 1_200,
|
||||
)
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(308)
|
||||
.setHeader("Location", server.url("/nc/login/v2/poll").toString()),
|
||||
)
|
||||
server.enqueue(
|
||||
json("""{"server":"${server.url("/")}","loginName":"me","appPassword":"secret-app-pw"}"""),
|
||||
)
|
||||
|
||||
val result = redirecting.poll(started, now = 1)
|
||||
|
||||
assertThat(result).isInstanceOf(NextcloudLoginFlow.PollResult.Approved::class.java)
|
||||
assertThat((result as NextcloudLoginFlow.PollResult.Approved).credentials.appPassword)
|
||||
.isEqualTo("secret-app-pw")
|
||||
server.takeRequest()
|
||||
val followed = server.takeRequest()
|
||||
assertThat(followed.method).isEqualTo("POST")
|
||||
assertThat(followed.body.readUtf8()).contains("token=tok-123")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init posts, and carries a User-Agent the user can recognise`() {
|
||||
startFlow()
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.method).isEqualTo("POST")
|
||||
// The User-Agent becomes the app password's *name* in Settings → Security
|
||||
// → Devices & sessions. With OkHttp's default the user sees
|
||||
// "okhttp/4.12.0" and cannot tell what to revoke.
|
||||
assertThat(request.getHeader("User-Agent")).isEqualTo("Agendula/1.0 (Pixel 8)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `polling is a POST - a GET gets 405`() {
|
||||
val started = startFlow()
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
flow.poll(started, now = 1)
|
||||
|
||||
server.takeRequest() // the init request
|
||||
val poll = server.takeRequest()
|
||||
assertThat(poll.method).isEqualTo("POST")
|
||||
assertThat(poll.body.readUtf8()).contains("token=tok-123")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `404 means pending`() {
|
||||
val started = startFlow()
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
assertThat(flow.poll(started, now = 1)).isEqualTo(NextcloudLoginFlow.PollResult.Pending)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `approval yields the app password, and loginName is kept as a username only`() {
|
||||
val started = startFlow()
|
||||
server.enqueue(
|
||||
json("""{"server":"${server.url("/")}","loginName":"Me@Example.COM","appPassword":"secret-app-pw"}"""),
|
||||
)
|
||||
val result = flow.poll(started, now = 1)
|
||||
assertThat(result).isInstanceOf(NextcloudLoginFlow.PollResult.Approved::class.java)
|
||||
val credentials = (result as NextcloudLoginFlow.PollResult.Approved).credentials
|
||||
assertThat(credentials.loginName).isEqualTo("Me@Example.COM")
|
||||
assertThat(credentials.appPassword).isEqualTo("secret-app-pw")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreachable claimed origin is replaced, and reported`() {
|
||||
val started = startFlow()
|
||||
server.enqueue(
|
||||
json("""{"server":"http://nextcloud:11000/","loginName":"me","appPassword":"pw"}"""),
|
||||
)
|
||||
|
||||
val result = flow.poll(started, now = 1) as NextcloudLoginFlow.PollResult.Approved
|
||||
|
||||
// We talk to the host that just answered us, not the one it named.
|
||||
assertThat(result.credentials.server.host).isEqualTo(server.hostName)
|
||||
// And the user is told which setting is wrong, rather than being sent
|
||||
// away with "no task lists" for what is a DNS failure.
|
||||
assertThat(result.hostMismatch?.actual).isEqualTo("nextcloud")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `429 is rate limiting, not pending`() {
|
||||
// "Anything that isn't 200 is pending" turns Nextcloud's brute-force
|
||||
// protection into a twenty-minute spinner.
|
||||
val started = startFlow()
|
||||
server.enqueue(MockResponse().setResponseCode(429))
|
||||
assertThat(flow.poll(started, now = 1))
|
||||
.isInstanceOf(NextcloudLoginFlow.PollResult.Failed::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `503 is maintenance, not pending`() {
|
||||
val started = startFlow()
|
||||
server.enqueue(MockResponse().setResponseCode(503))
|
||||
assertThat(flow.poll(started, now = 1))
|
||||
.isInstanceOf(NextcloudLoginFlow.PollResult.Failed::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 200 that is not JSON is a captive portal, not an approval`() {
|
||||
// A Cloudflare challenge is a 200 carrying HTML.
|
||||
val started = startFlow()
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(200)
|
||||
.setHeader("Content-Type", "text/html")
|
||||
.setBody("<html>Checking your browser…</html>"),
|
||||
)
|
||||
assertThat(flow.poll(started, now = 1))
|
||||
.isInstanceOf(NextcloudLoginFlow.PollResult.Failed::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the deadline is tracked locally, because 404 also means expired`() {
|
||||
val started = startFlow()
|
||||
assertThat(started.deadlineEpochSeconds).isEqualTo(1200L)
|
||||
// No response enqueued: an expired flow must not even reach the network.
|
||||
assertThat(flow.poll(started, now = 1201))
|
||||
.isInstanceOf(NextcloudLoginFlow.PollResult.Expired::class.java)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a URL that downgrades to http is refused`() {
|
||||
// The poll token is exchanged for a long-lived app password, and the login
|
||||
// URL takes the account password — both are credential-grade.
|
||||
val https = "https://cloud.example.com/".toHttpUrl()
|
||||
val http = "http://cloud.example.com/poll".toHttpUrl()
|
||||
val failure = runCatching { flow.requireSecureOrigin(https, http) }.exceptionOrNull()
|
||||
assertThat(failure).isNotNull()
|
||||
assertThat(failure!!.message).contains("http://")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the browser login URL gets the same scheme check as the poll endpoint`() {
|
||||
// It is where the user types their *account* password, so leaving it
|
||||
// unchecked is the more dangerous of the two omissions. `start` applies
|
||||
// requireSecureOrigin to both; this asserts the rule itself, because
|
||||
// reaching the branch over the wire would need a TLS MockWebServer and a
|
||||
// test that merely fails to connect would pass for the wrong reason.
|
||||
val typed = "https://cloud.example.com/".toHttpUrl()
|
||||
val harvester = "http://evil.example.com/harvest".toHttpUrl()
|
||||
val failure = runCatching { flow.requireSecureOrigin(typed, harvester) }.exceptionOrNull()
|
||||
assertThat(failure).isNotNull()
|
||||
assertThat(failure!!.message).contains("evil.example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a host change is carried, not refused - reverse proxies are legitimate`() {
|
||||
// overwrite.cli.url pointing somewhere other than what the user typed is
|
||||
// ordinary on self-hosted installs. Throwing would make the flow unusable
|
||||
// for them; the UI confirms it instead.
|
||||
server.enqueue(
|
||||
json(
|
||||
"""
|
||||
{"poll":{"token":"t","endpoint":"https://cloud.example.com/poll"},
|
||||
"login":"https://cloud.example.com/flow"}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
val started = flow.start(server.url("/"), now = 0)
|
||||
assertThat(started.isSuccess).isTrue()
|
||||
val mismatch = started.getOrThrow().hostMismatch
|
||||
assertThat(mismatch).isNotNull()
|
||||
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 `a cleartext port does not survive the coercion`() {
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
|
||||
val downgraded = "http://cloud.example.com:8080/".toHttpUrl()
|
||||
|
||||
// ⚠️ OkHttp only drops a *default* port across a scheme change, so
|
||||
// :8080 would be carried into https — a port that almost certainly
|
||||
// speaks cleartext, turning the promised coercion into a handshake
|
||||
// failure. The port that answered the poll is the one known to work.
|
||||
val coerced = flow.secureOrigin(expected, downgraded)
|
||||
|
||||
assertThat(coerced.scheme).isEqualTo("https")
|
||||
assertThat(coerced.port).isEqualTo(443)
|
||||
}
|
||||
|
||||
@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()
|
||||
|
||||
// secureOrigin keeps its narrow job: the scheme. Which origin we then
|
||||
// actually talk to is reachableOrigin's decision.
|
||||
assertThat(flow.secureOrigin(expected, actual)).isEqualTo(actual)
|
||||
}
|
||||
|
||||
@Test fun `an origin outside the polled domain is replaced by the one that answered`() {
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val expected = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
|
||||
|
||||
// ⚠️ The ordinary reverse-proxied install: the poll endpoint comes from
|
||||
// overwrite.cli.url and is right, while `server` is built from the
|
||||
// approving request's own Host header and is an internal name the phone
|
||||
// cannot resolve. Following it strands the user with a spent password.
|
||||
val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/".toHttpUrl())
|
||||
|
||||
assertThat(reachable.host).isEqualTo("cloud.example.com")
|
||||
assertThat(reachable.isHttps).isTrue()
|
||||
assertThat(reachable.encodedPath).isEqualTo("/")
|
||||
}
|
||||
|
||||
@Test fun `a sibling host is used verbatim and not reported as a mismatch`() {
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val polled = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
|
||||
val claimed = "https://nc.example.com/".toHttpUrl()
|
||||
|
||||
// ⚠️ hostMismatchOf compares hosts exactly; reachableOrigin substitutes
|
||||
// only across registrable domains. Reporting the raw comparison tells the
|
||||
// user we replaced an address we in fact used, and blames a correct
|
||||
// setting — stickily, for the rest of the flow.
|
||||
val used = flow.reachableOrigin(polled, claimed)
|
||||
assertThat(used).isEqualTo(claimed)
|
||||
assertThat(flow.substitutedMismatch(polled, claimed, used)).isNull()
|
||||
}
|
||||
|
||||
@Test fun `a substituted host is reported`() {
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val polled = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
|
||||
val claimed = "http://nextcloud:11000/".toHttpUrl()
|
||||
|
||||
val used = flow.reachableOrigin(polled, claimed)
|
||||
assertThat(flow.substitutedMismatch(polled, claimed, used)?.actual).isEqualTo("nextcloud")
|
||||
}
|
||||
|
||||
@Test fun `a subdirectory install without index_php keeps its prefix`() {
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
// htaccess.IgnoreFrontController drops index.php from generated routes.
|
||||
val expected = "https://cloud.example.com/nc/login/v2/poll".toHttpUrl()
|
||||
|
||||
val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/".toHttpUrl())
|
||||
|
||||
assertThat(reachable.encodedPath).isEqualTo("/nc/")
|
||||
}
|
||||
|
||||
@Test fun `a sibling host in the same domain is still honoured`() {
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val expected = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
|
||||
val actual = "https://dav.example.com/".toHttpUrl()
|
||||
|
||||
// A real deployment shape, and the credential is scoped to that
|
||||
// registrable domain anyway.
|
||||
assertThat(flow.reachableOrigin(expected, actual)).isEqualTo(actual)
|
||||
}
|
||||
|
||||
@Test fun `a subdirectory install keeps its prefix when the host is replaced`() {
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val expected =
|
||||
"https://cloud.example.com/nextcloud/index.php/login/v2/poll".toHttpUrl()
|
||||
|
||||
// ⚠️ The container's own webroot is empty, so the claimed URL carries no
|
||||
// prefix at all. Keeping the claimed path alongside the polled host would
|
||||
// drop /nextcloud and send discovery to the wrong base — with the app
|
||||
// password already spent.
|
||||
val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/".toHttpUrl())
|
||||
|
||||
assertThat(reachable.host).isEqualTo("cloud.example.com")
|
||||
assertThat(reachable.encodedPath).isEqualTo("/nextcloud/")
|
||||
}
|
||||
|
||||
@Test fun `a webroot install is rebuilt at the root`() {
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val expected = "https://cloud.example.com/index.php/login/v2/poll".toHttpUrl()
|
||||
|
||||
val reachable = flow.reachableOrigin(expected, "http://nextcloud:11000/whatever".toHttpUrl())
|
||||
|
||||
// The claimed path goes with the claimed host: both came from the
|
||||
// generator we just decided not to trust.
|
||||
assertThat(reachable.encodedPath).isEqualTo("/")
|
||||
}
|
||||
|
||||
@Test fun `a single-label host is compared exactly, not by a null domain`() {
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val expected = "https://localhost:8443/index.php/login/v2/poll".toHttpUrl()
|
||||
|
||||
// topPrivateDomain() is null for both sides here; falling back to the
|
||||
// exact host is what keeps a homelab install working.
|
||||
assertThat(flow.reachableOrigin(expected, "https://localhost:8443/".toHttpUrl()).host)
|
||||
.isEqualTo("localhost")
|
||||
assertThat(flow.reachableOrigin(expected, "https://elsewhere/".toHttpUrl()).host)
|
||||
.isEqualTo("localhost")
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class ResourceNamesTest {
|
||||
|
||||
@Test fun `an ordinary uid becomes itself`() {
|
||||
assertThat(ResourceNames.forUid("a1b2c3")).isEqualTo("a1b2c3.ics")
|
||||
}
|
||||
|
||||
@Test fun `path separators never survive`() {
|
||||
// The whole point: a UID is opaque text, an href is a path segment.
|
||||
assertThat(ResourceNames.forUid("../../etc/passwd")).doesNotContain("/")
|
||||
assertThat(ResourceNames.forUid("a/b")).isEqualTo("a-b.ics")
|
||||
}
|
||||
|
||||
@Test fun `at signs are excluded even though they are legal in a path`() {
|
||||
assertThat(ResourceNames.forUid("task@example.com")).isEqualTo("task-example.com.ics")
|
||||
}
|
||||
|
||||
@Test fun `the basename is capped`() {
|
||||
val name = ResourceNames.forUid("x".repeat(500))
|
||||
assertThat(name.removeSuffix(".ics")).hasLength(ResourceNames.MAX_BASENAME_BYTES)
|
||||
}
|
||||
|
||||
@Test fun `a uid with nothing usable in it falls back to a uuid`() {
|
||||
val name = ResourceNames.forUid("///")
|
||||
assertThat(name).endsWith(".ics")
|
||||
assertThat(name.removeSuffix(".ics")).matches("[0-9a-f-]{36}")
|
||||
}
|
||||
|
||||
@Test fun `dots alone do not become a relative path`() {
|
||||
assertThat(ResourceNames.forUid("..")).doesNotContain("..")
|
||||
assertThat(ResourceNames.forUid(".")).endsWith(".ics")
|
||||
}
|
||||
|
||||
@Test fun `random names do not repeat`() {
|
||||
assertThat(ResourceNames.random()).isNotEqualTo(ResourceNames.random())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a collection name keeps no extension`() {
|
||||
// A collection is a directory, not a file. `.ics` on the end of one is
|
||||
// wrong in a way that only shows up when someone looks at the server.
|
||||
assertThat(ResourceNames.forCollection("Shopping")).isEqualTo("Shopping")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a name that does not survive sanitising gets a random segment`() {
|
||||
// "Einkäufe 🛒" is an ordinary thing to call a list, and none of it is
|
||||
// in the safe class. A random segment is the honest answer; the display
|
||||
// name the user sees is unaffected.
|
||||
val name = ResourceNames.forCollection("🛒")
|
||||
|
||||
assertThat(name).isNotEmpty()
|
||||
assertThat(name).doesNotContain("🛒")
|
||||
assertThat(name).doesNotContain(ResourceNames.EXTENSION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two random collection segments differ`() {
|
||||
// The retry after Nextcloud's trashbin 403 depends on this: re-creating
|
||||
// under a segment used before answers 403 for ever.
|
||||
assertThat(ResourceNames.randomCollection())
|
||||
.isNotEqualTo(ResourceNames.randomCollection())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The per-server trap matrix.
|
||||
*
|
||||
* Everything reproducible from the protocol alone runs here against
|
||||
* MockWebServer. The handful that genuinely need a live server are `@Ignore`d
|
||||
* with the reason spelled out — they are named so they can be turned on by hand,
|
||||
* not deleted and rediscovered.
|
||||
*/
|
||||
class ServerMatrixTest {
|
||||
|
||||
private val server = MockWebServer()
|
||||
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
|
||||
private lateinit var collection: CalendarCollection
|
||||
|
||||
@Before fun start() {
|
||||
server.start()
|
||||
collection = CalendarCollection(httpClient, server.url("/dav/tasks/"))
|
||||
}
|
||||
|
||||
@After fun stop() = server.shutdown()
|
||||
|
||||
// ------------------------------------------------------------- Nextcloud
|
||||
|
||||
@Test fun `Nextcloud per-calendar UID uniqueness is a rejection, not a retry`() {
|
||||
// 409 no-uid-conflict: another resource in this collection already has
|
||||
// that UID. Retrying the same body reproduces it exactly.
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(409)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody(
|
||||
"<D:error xmlns:D=\"DAV:\" xmlns:C=\"urn:ietf:params:xml:ns:caldav\">" +
|
||||
"<C:no-uid-conflict/></D:error>",
|
||||
),
|
||||
)
|
||||
|
||||
val outcome = collection.create("one.ics", "x")
|
||||
|
||||
assertThat(outcome).isInstanceOf(PutOutcome.Rejected::class.java)
|
||||
assertThat((outcome as PutOutcome.Rejected).code).isEqualTo(409)
|
||||
}
|
||||
|
||||
@Test fun `Nextcloud's trashbin makes a reused href answer 403`() {
|
||||
// The trashbin renames a deleted resource to `<name>-deleted.ics`, so
|
||||
// delete, recreate and delete again at the same href returns 403. Task
|
||||
// apps hit this constantly because they reuse hrefs.
|
||||
server.enqueue(MockResponse().setResponseCode(403))
|
||||
|
||||
val outcome = collection.delete(server.url("/dav/tasks/one.ics"), "e")
|
||||
|
||||
// Not "already gone" and not a conflict — a refusal that must be
|
||||
// quarantined rather than retried forever.
|
||||
assertThat(outcome).isInstanceOf(DeleteOutcome.Rejected::class.java)
|
||||
assertThat((outcome as DeleteOutcome.Rejected).code).isEqualTo(403)
|
||||
}
|
||||
|
||||
@Test fun `a shared Nextcloud calendar is recognised as shared`() {
|
||||
// ⚠️ The whole mutilation guard hangs off this bit. `CalendarObject::get()`
|
||||
// serves a whitelist-reduced body from a share while leaving the ETag
|
||||
// untouched, so an engine that thinks the collection is not shared will
|
||||
// write that reduced body back over the owner's task.
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody(
|
||||
"""
|
||||
<multistatus xmlns="DAV:">
|
||||
<response>
|
||||
<href>/dav/tasks/</href>
|
||||
<propstat><prop><resourcetype>
|
||||
<collection/>
|
||||
<C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/>
|
||||
<CS:shared xmlns:CS="http://calendarserver.org/ns/"/>
|
||||
</resourcetype></prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
</multistatus>
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(collection.state().getOrThrow().collection.isShared).isTrue()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ SOGo
|
||||
|
||||
@Test fun `SOGo's second-granularity token is carried verbatim`() {
|
||||
// SOGo's tokens are second-granularity integers rather than URIs. Nothing
|
||||
// may parse, normalise or compare them as anything but opaque text.
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody(
|
||||
"<multistatus xmlns=\"DAV:\"><sync-token>1730000000</sync-token></multistatus>",
|
||||
),
|
||||
)
|
||||
|
||||
val page = collection.changes("1729999999") as ChangeSet.Page
|
||||
|
||||
assertThat(page.token).isEqualTo("1730000000")
|
||||
assertThat(server.takeRequest().body.readUtf8()).contains("1729999999")
|
||||
}
|
||||
|
||||
@Test fun `SOGo's main calendar survives classification`() {
|
||||
// ⚠️ SOGo reports collection + calendar + schedule-outbox together for
|
||||
// every non-Apple client. The obvious "exclude schedule-outbox" rule
|
||||
// drops the user's only calendar.
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody(
|
||||
"""
|
||||
<multistatus xmlns="DAV:">
|
||||
<response>
|
||||
<href>/dav/tasks/</href>
|
||||
<propstat><prop><resourcetype>
|
||||
<collection/>
|
||||
<C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/>
|
||||
<C:schedule-outbox xmlns:C="urn:ietf:params:xml:ns:caldav"/>
|
||||
</resourcetype></prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
</multistatus>
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
assertThat(collection.state().isSuccess).isTrue()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- Radicale
|
||||
|
||||
@Test fun `Radicale advertising sync-collection without meaning it degrades`() {
|
||||
// Radicale advertised the report for years without implementing it, so a
|
||||
// 501 must fall back rather than fail the collection.
|
||||
server.enqueue(MockResponse().setResponseCode(501))
|
||||
assertThat(collection.changes("t")).isEqualTo(ChangeSet.Unsupported)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------- needs a real server
|
||||
|
||||
@Ignore(
|
||||
"Needs a live Baikal on dav_auth_type = Digest. OkHttp has no Digest of " +
|
||||
"its own (square/okhttp#205), so this exercises the vendored " +
|
||||
"BasicDigestAuthHandler against a real challenge/nonce cycle, which " +
|
||||
"MockWebServer cannot reproduce faithfully.",
|
||||
)
|
||||
@Test fun `Baikal on Digest authenticates`() = Unit
|
||||
|
||||
@Ignore(
|
||||
"Needs a live Nextcloud with a calendar shared read-write from another " +
|
||||
"account, holding a CLASS:CONFIDENTIAL task. Verifies that we never " +
|
||||
"write back the whitelist-reduced body CalendarObject::get() serves " +
|
||||
"— the single most destructive bug available here, and one no mock " +
|
||||
"can prove absent because the reduction happens server-side.",
|
||||
)
|
||||
@Test fun `a confidential task on a shared Nextcloud calendar survives a round trip`() = Unit
|
||||
|
||||
@Ignore(
|
||||
"Needs a live SOGo. Its ETag is a row-version counter and the body is " +
|
||||
"regenerated per principal, so the same ETag can accompany different " +
|
||||
"bytes. Proving we do not silently keep a stale body needs the real " +
|
||||
"server's regeneration behaviour.",
|
||||
)
|
||||
@Test fun `an ETag-unchanged body change on SOGo is detected`() = Unit
|
||||
|
||||
@Ignore(
|
||||
"Needs a live Nextcloud. MKCALENDAR is rate-limited to 10 per hour, " +
|
||||
"which is the behaviour under test and cannot be mocked usefully.",
|
||||
)
|
||||
@Test fun `Nextcloud rate-limits collection creation`() = Unit
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
class ServerQuirksTest {
|
||||
|
||||
@Test
|
||||
fun `the three providers whose real error is not wrong password`() {
|
||||
assertThat(ServerQuirk.forInput("me@fastmail.com"))
|
||||
.isEqualTo(ServerQuirk.FASTMAIL_APP_PASSWORD)
|
||||
assertThat(ServerQuirk.forInput("me@icloud.com"))
|
||||
.isEqualTo(ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD)
|
||||
assertThat(ServerQuirk.forInput("me@gmail.com"))
|
||||
.isEqualTo(ServerQuirk.GOOGLE_UNSUPPORTED)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Google is fatal - it supports neither VTODO nor MKCALENDAR`() {
|
||||
assertThat(ServerQuirk.GOOGLE_UNSUPPORTED.isFatal).isTrue()
|
||||
assertThat(ServerQuirk.FASTMAIL_APP_PASSWORD.isFatal).isFalse()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `subdomains count, and a lookalike domain does not`() {
|
||||
assertThat(ServerQuirk.forHost("mail.icloud.com")).isEqualTo(ServerQuirk.ICLOUD_APP_SPECIFIC_PASSWORD)
|
||||
assertThat(ServerQuirk.forHost("noticloud.com")).isNull()
|
||||
assertThat(ServerQuirk.forHost("icloud.com.example.org")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a service that cannot be synced is offered last, not omitted`() {
|
||||
val offered = CalDavProvider.selectable
|
||||
// Listed, because people go looking for it and an absent row reads as
|
||||
// the app being unfinished rather than as Google's own limitation.
|
||||
assertThat(offered).contains(CalDavProvider.GOOGLE)
|
||||
assertThat(offered.last()).isEqualTo(CalDavProvider.GOOGLE)
|
||||
// Everything else keeps declaration order — the sort only moves the
|
||||
// dead rows, so Nextcloud stays the first thing offered.
|
||||
assertThat(offered.first()).isEqualTo(CalDavProvider.NEXTCLOUD)
|
||||
assertThat(offered.none { ServerQuirk.forProvider(it)?.isFatal == true && it != CalDavProvider.GOOGLE })
|
||||
.isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a self-hosted server has no quirk`() {
|
||||
assertThat(ServerQuirk.forInput("https://cloud.example.de/remote.php/dav/")).isNull()
|
||||
assertThat(ServerQuirk.forInput("me@example.de")).isNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The discovery trap table, made executable. Every case here was
|
||||
* live-probed against a real provider; each one breaks the obvious
|
||||
* implementation.
|
||||
*/
|
||||
class ServiceDiscoveryTest {
|
||||
|
||||
private class FakeDns(
|
||||
val srv: Map<String, List<SrvRecord>> = emptyMap(),
|
||||
val txt: Map<String, List<String>> = emptyMap(),
|
||||
) : DnsResolver {
|
||||
override fun srv(name: String) = srv[name].orEmpty()
|
||||
override fun txt(name: String) = txt[name].orEmpty()
|
||||
}
|
||||
|
||||
private fun urls(input: String, dns: DnsResolver = DnsResolver.None) =
|
||||
ServiceDiscovery.candidatesFor(input, dns).map { it.url.toString() }
|
||||
|
||||
@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"))))
|
||||
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
|
||||
fun `the well-known ladder ends at the root, not at well-known`() {
|
||||
// The draft stopped at /.well-known/caldav; a server that 404s there and
|
||||
// serves DAV from / would be undiscoverable.
|
||||
assertThat(urls("me@example.com")).containsExactly(
|
||||
"https://example.com/.well-known/caldav",
|
||||
"https://example.com/",
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Posteo is SRV-only on port 8443, and its well-known 404s`() {
|
||||
val dns = FakeDns(
|
||||
srv = mapOf("_caldavs._tcp.posteo.de" to listOf(SrvRecord(0, 0, 8443, "posteo.de."))),
|
||||
txt = mapOf("_caldavs._tcp.posteo.de" to listOf("path=/")),
|
||||
)
|
||||
// Hardcoding 443 fails Posteo outright.
|
||||
assertThat(urls("me@posteo.de", dns)).containsExactly(
|
||||
"https://posteo.de:8443/",
|
||||
"https://posteo.de:8443/.well-known/caldav",
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GMX publishes a TXT path that discovery cannot skip`() {
|
||||
val dns = FakeDns(
|
||||
txt = mapOf("_caldavs._tcp.gmx.net" to listOf("path=/begenda/dav/users/")),
|
||||
)
|
||||
// Skip the TXT lookup and GMX lands on the wrong path, finding nothing.
|
||||
assertThat(urls("me@gmx.net", dns)).contains("https://gmx.net/begenda/dav/users/")
|
||||
assertThat(urls("me@gmx.net", dns).first()).isEqualTo("https://gmx.net/begenda/dav/users/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a null SRV target means explicitly unavailable, not no record`() {
|
||||
// RFC 2782. _caldav._tcp.fastmail.com and runbox.com both answer `0 0 0 .`
|
||||
val dns = FakeDns(srv = mapOf("_caldavs._tcp.runbox.com" to listOf(SrvRecord(0, 0, 0, "."))))
|
||||
assertThat(urls("me@runbox.com", dns)).containsExactly(
|
||||
"https://runbox.com/.well-known/caldav",
|
||||
"https://runbox.com/",
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Google's SRV record points at something that is not a DAV server`() {
|
||||
// calendar.google.com answers 405 to PROPFIND. A strict RFC 6764 client
|
||||
// follows it into a dead end for every @gmail.com address.
|
||||
val dns = FakeDns(
|
||||
srv = mapOf("_caldavs._tcp.gmail.com" to listOf(SrvRecord(5, 0, 443, "calendar.google.com."))),
|
||||
)
|
||||
assertThat(urls("me@gmail.com", dns)).doesNotContain("https://calendar.google.com/")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an SRV target outside the queried domain is refused`() {
|
||||
// Plain UDP DNS, no DNSSEC, and the ladder is walked again after a 401
|
||||
// with the authenticated client. The credential is scoped to the typed
|
||||
// address's registrable domain anyway, so this target could only ever
|
||||
// answer 401.
|
||||
val dns = FakeDns(
|
||||
srv = mapOf(
|
||||
"_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 443, "dav.attacker.test.")),
|
||||
),
|
||||
)
|
||||
val candidates = urls("me@example.com", dns)
|
||||
|
||||
assertThat(candidates).doesNotContain("https://dav.attacker.test/.well-known/caldav")
|
||||
// The typed domain's own ladder still runs.
|
||||
assertThat(candidates).contains("https://example.com/.well-known/caldav")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a subdomain SRV target is still followed`() {
|
||||
val dns = FakeDns(
|
||||
srv = mapOf(
|
||||
"_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 443, "caldav.example.com.")),
|
||||
),
|
||||
)
|
||||
assertThat(urls("me@example.com", dns))
|
||||
.contains("https://caldav.example.com/.well-known/caldav")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SRV priority wins, then weight`() {
|
||||
val dns = FakeDns(
|
||||
srv = mapOf(
|
||||
"_caldavs._tcp.example.com" to listOf(
|
||||
SrvRecord(priority = 20, weight = 0, port = 443, target = "backup.example.com"),
|
||||
SrvRecord(priority = 10, weight = 5, port = 443, target = "low.example.com"),
|
||||
SrvRecord(priority = 10, weight = 90, port = 443, target = "main.example.com"),
|
||||
),
|
||||
),
|
||||
)
|
||||
assertThat(urls("me@example.com", dns).first()).startsWith("https://main.example.com/")
|
||||
assertThat(urls("me@example.com", dns)).containsAtLeast(
|
||||
"https://main.example.com/.well-known/caldav",
|
||||
"https://low.example.com/.well-known/caldav",
|
||||
"https://backup.example.com/.well-known/caldav",
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `port 443 stays implicit so URLs compare equal`() {
|
||||
val dns = FakeDns(srv = mapOf("_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 443, "dav.example.com"))))
|
||||
assertThat(urls("me@example.com", dns).first()).isEqualTo("https://dav.example.com/.well-known/caldav")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mailto and a bare domain both resolve`() {
|
||||
assertThat(ServiceDiscovery.domainOf("mailto:me@example.com")).isEqualTo("example.com")
|
||||
assertThat(ServiceDiscovery.domainOf("me@example.com")).isEqualTo("example.com")
|
||||
assertThat(ServiceDiscovery.domainOf("example.com")).isEqualTo("example.com")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `something that is neither an address nor a URL yields nothing`() {
|
||||
assertThat(ServiceDiscovery.candidatesFor("hello")).isEmpty()
|
||||
assertThat(ServiceDiscovery.candidatesFor("")).isEmpty()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every candidate carries where it came from`() {
|
||||
val dns = FakeDns(txt = mapOf("_caldavs._tcp.gmx.net" to listOf("path=/begenda/dav/users/")))
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.time.Instant
|
||||
|
||||
class WebDavPushTest {
|
||||
|
||||
private val server = MockWebServer()
|
||||
private val httpClient = OkHttpClient.Builder().followRedirects(false).build()
|
||||
|
||||
@Before fun start() = server.start()
|
||||
|
||||
@After fun stop() = server.shutdown()
|
||||
|
||||
private fun collectionUrl() = server.url("/dav/tasks/")
|
||||
|
||||
private val requested = Instant.parse("2026-10-01T10:00:00Z")
|
||||
|
||||
// ------------------------------------------------------------ discovery
|
||||
|
||||
@Test fun `state reads the topic and VAPID key`() {
|
||||
server.enqueue(stateResponse(push = PUSH_PROPS))
|
||||
|
||||
val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push
|
||||
|
||||
assertThat(push).isEqualTo(PushSupport(topic = "O7M1nQ7cKkKTKsoS_j6Z3w", vapidPublicKey = VAPID))
|
||||
}
|
||||
|
||||
@Test fun `state asks for the push properties by name`() {
|
||||
server.enqueue(stateResponse(push = ""))
|
||||
CalendarCollection(httpClient, collectionUrl()).state().getOrThrow()
|
||||
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertThat(body).contains("https://bitfire.at/webdav-push")
|
||||
assertThat(body).contains("transports")
|
||||
assertThat(body).contains("topic")
|
||||
}
|
||||
|
||||
@Test fun `a topic without a web-push transport is no support`() {
|
||||
server.enqueue(stateResponse(push = """<P:topic>t1</P:topic>"""))
|
||||
|
||||
val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push
|
||||
|
||||
assertThat(push).isNull()
|
||||
}
|
||||
|
||||
@Test fun `a web-push transport without a topic is no support`() {
|
||||
server.enqueue(stateResponse(push = """<P:transports><P:web-push/></P:transports>"""))
|
||||
|
||||
val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push
|
||||
|
||||
assertThat(push).isNull()
|
||||
}
|
||||
|
||||
@Test fun `web-push without a VAPID key is still support`() {
|
||||
server.enqueue(
|
||||
stateResponse(push = """<P:transports><P:web-push/></P:transports><P:topic>t1</P:topic>"""),
|
||||
)
|
||||
|
||||
val push = CalendarCollection(httpClient, collectionUrl()).state().getOrThrow().collection.push
|
||||
|
||||
assertThat(push).isEqualTo(PushSupport(topic = "t1", vapidPublicKey = null))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- registration
|
||||
|
||||
@Test fun `register posts the subscription and reads Location and Expires`() {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(201)
|
||||
.setHeader("Location", "/dav/subscriptions/io6Efei4ooph")
|
||||
.setHeader("Expires", "Sat, 03 Oct 2026 07:28:00 GMT"),
|
||||
)
|
||||
|
||||
val outcome = WebDavPush.register(
|
||||
httpClient, collectionUrl(), "https://up.example.net/abc", "PUBKEY", "AUTH", requested,
|
||||
)
|
||||
|
||||
assertThat(outcome).isEqualTo(
|
||||
WebDavPush.Registration.Registered(
|
||||
url = server.url("/dav/subscriptions/io6Efei4ooph"),
|
||||
expires = Instant.parse("2026-10-03T07:28:00Z"),
|
||||
),
|
||||
)
|
||||
val request = server.takeRequest()
|
||||
assertThat(request.method).isEqualTo("POST")
|
||||
assertThat(request.path).isEqualTo("/dav/tasks/")
|
||||
val body = request.body.readUtf8()
|
||||
assertThat(body).contains("push-register")
|
||||
assertThat(body).contains("https://up.example.net/abc")
|
||||
assertThat(body).contains("aes128gcm")
|
||||
assertThat(body).contains("PUBKEY")
|
||||
assertThat(body).contains("AUTH")
|
||||
assertThat(body).contains("content-update")
|
||||
assertThat(body).contains("Thu, 01 Oct 2026 10:00:00 GMT")
|
||||
}
|
||||
|
||||
@Test fun `register without Expires keeps the requested date`() {
|
||||
server.enqueue(MockResponse().setResponseCode(204).setHeader("Location", "/sub/1"))
|
||||
|
||||
val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", null, null, requested)
|
||||
|
||||
assertThat(outcome).isEqualTo(WebDavPush.Registration.Registered(server.url("/sub/1"), requested))
|
||||
}
|
||||
|
||||
@Test fun `register leaves the keys out when there are none`() {
|
||||
server.enqueue(MockResponse().setResponseCode(204).setHeader("Location", "/sub/1"))
|
||||
|
||||
WebDavPush.register(httpClient, collectionUrl(), "https://up/1", null, null, requested)
|
||||
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertThat(body).doesNotContain("content-encoding")
|
||||
assertThat(body).doesNotContain("subscription-public-key")
|
||||
}
|
||||
|
||||
@Test fun `a 403 is a refusal`() {
|
||||
server.enqueue(MockResponse().setResponseCode(403))
|
||||
|
||||
val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", "k", "a", requested)
|
||||
|
||||
assertThat(outcome).isEqualTo(WebDavPush.Registration.Refused(403))
|
||||
}
|
||||
|
||||
@Test fun `a 500 is a failure, not a refusal`() {
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", "k", "a", requested)
|
||||
|
||||
assertThat(outcome).isInstanceOf(WebDavPush.Registration.Failed::class.java)
|
||||
}
|
||||
|
||||
@Test fun `a 500 carries sabre's exception message`() {
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(500)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody(
|
||||
"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:error xmlns:d="DAV:" xmlns:s="http://sabredav.org/ns">
|
||||
<s:exception>TypeError</s:exception>
|
||||
<s:message>Call to a member function getTimestamp() on bool</s:message>
|
||||
</d:error>""",
|
||||
),
|
||||
)
|
||||
|
||||
val outcome = WebDavPush.register(httpClient, collectionUrl(), "https://up/1", "k", "a", requested)
|
||||
|
||||
assertThat(outcome).isEqualTo(
|
||||
WebDavPush.Registration.Failed("HTTP 500: Call to a member function getTimestamp() on bool"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `unregister counts an already expired subscription as removed`() {
|
||||
server.enqueue(MockResponse().setResponseCode(404))
|
||||
|
||||
assertThat(WebDavPush.unregister(httpClient, server.url("/sub/1"))).isTrue()
|
||||
assertThat(server.takeRequest().method).isEqualTo("DELETE")
|
||||
}
|
||||
|
||||
@Test fun `unregister reports a server error as not removed`() {
|
||||
server.enqueue(MockResponse().setResponseCode(500))
|
||||
|
||||
assertThat(WebDavPush.unregister(httpClient, server.url("/sub/1"))).isFalse()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- writes
|
||||
|
||||
@Test fun `writes name our subscription in Push-Dont-Notify, reads do not`() {
|
||||
val registration = server.url("/sub/1")
|
||||
val collection = CalendarCollection(httpClient, collectionUrl(), pushRegistration = registration)
|
||||
server.enqueue(MockResponse().setResponseCode(201).setHeader("ETag", "\"e1\""))
|
||||
server.enqueue(MockResponse().setResponseCode(204))
|
||||
server.enqueue(stateResponse(push = ""))
|
||||
|
||||
collection.create("a.ics", "BEGIN:VCALENDAR")
|
||||
collection.delete(collectionUrl().resolve("a.ics")!!, "e1")
|
||||
collection.state()
|
||||
|
||||
assertThat(server.takeRequest().getHeader("Push-Dont-Notify")).isEqualTo("\"$registration\"")
|
||||
assertThat(server.takeRequest().getHeader("Push-Dont-Notify")).isEqualTo("\"$registration\"")
|
||||
assertThat(server.takeRequest().getHeader("Push-Dont-Notify")).isNull()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- messages
|
||||
|
||||
@Test fun `a content update carries its topic and sync token`() {
|
||||
val message = WebDavPush.parse(
|
||||
"""
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<push-message xmlns="https://bitfire.at/webdav-push" xmlns:D="DAV:">
|
||||
<topic>O7M1nQ7cKkKTKsoS_j6Z3w</topic>
|
||||
<content-update>
|
||||
<D:sync-token>http://example.com/sync/10</D:sync-token>
|
||||
</content-update>
|
||||
<property-update />
|
||||
</push-message>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertThat(message).isEqualTo(
|
||||
WebDavPush.Message(
|
||||
topic = "O7M1nQ7cKkKTKsoS_j6Z3w",
|
||||
syncToken = "http://example.com/sync/10",
|
||||
keyRotation = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `a transports update without a topic is a key rotation`() {
|
||||
val message = WebDavPush.parse(
|
||||
"""
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<push-message xmlns="https://bitfire.at/webdav-push" xmlns:D="DAV:">
|
||||
<property-update>
|
||||
<D:prop>
|
||||
<transports />
|
||||
</D:prop>
|
||||
</property-update>
|
||||
</push-message>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertThat(message).isEqualTo(WebDavPush.Message(topic = null, syncToken = null, keyRotation = true))
|
||||
}
|
||||
|
||||
@Test fun `anything that is not a push message is null`() {
|
||||
assertThat(WebDavPush.parse("not xml")).isNull()
|
||||
assertThat(WebDavPush.parse("<multistatus xmlns=\"DAV:\"/>")).isNull()
|
||||
}
|
||||
|
||||
private fun stateResponse(push: String) = MockResponse()
|
||||
.setResponseCode(207)
|
||||
.setHeader("Content-Type", "application/xml; charset=utf-8")
|
||||
.setBody(
|
||||
"""
|
||||
<multistatus xmlns="DAV:" xmlns:P="https://bitfire.at/webdav-push">
|
||||
<response>
|
||||
<href>/dav/tasks/</href>
|
||||
<propstat><prop>
|
||||
<resourcetype><collection/><C:calendar xmlns:C="urn:ietf:params:xml:ns:caldav"/></resourcetype>
|
||||
$push
|
||||
</prop><status>HTTP/1.1 200 OK</status></propstat>
|
||||
</response>
|
||||
</multistatus>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val VAPID =
|
||||
"BA1Hxzyi1RUM1b5wjxsn7nGxAszw2u61m164i3MrAIxHF6YK5h4SDYic-dRuU_RCPCfA5aq9ojSwk5Y2EmClBPs"
|
||||
|
||||
const val PUSH_PROPS = """
|
||||
<P:transports>
|
||||
<P:web-push>
|
||||
<P:vapid-public-key type="p256ecdsa">$VAPID</P:vapid-public-key>
|
||||
</P:web-push>
|
||||
</P:transports>
|
||||
<P:topic>O7M1nQ7cKkKTKsoS_j6Z3w</P:topic>
|
||||
<P:supported-triggers>
|
||||
<P:content-update><depth>1</depth></P:content-update>
|
||||
</P:supported-triggers>
|
||||
"""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user