sync: report why discovery failed after a browser approval

Every non-Found outcome was collapsed into NO_CALENDARS, so a server
whose login flow named an unresolvable host told the user their account
holds no task lists. Both Failed and NotCalDav already carry the cause
discovery worked out; forward it and keep NO_CALENDARS for the outcomes
that really mean it.

This is what turned the overwrite.cli.url skew from confusing into
undiagnosable: the user retries, gets the same wrong answer, and leaves
another spent app password in their device list each time.
This commit is contained in:
2026-09-07 21:59:20 +02:00
parent fc7e520aa6
commit 3a9f623d2a
2 changed files with 43 additions and 4 deletions
@@ -231,10 +231,15 @@ class AddAccountViewModel @Inject constructor(
result.credentials.server, result.credentials.server,
), ),
) )
if (outcome is CalDavDiscovery.Outcome.Found) { when (outcome) {
onDiscovered(outcome) is CalDavDiscovery.Outcome.Found -> onDiscovered(outcome)
} else { // ⚠️ Forward what actually happened. Reporting every
backToServer(CalDavDiscovery.Outcome.Cause.NO_CALENDARS) // outcome as NO_CALENDARS tells someone whose server
// named an unresolvable host that their account holds
// no task lists, which is both wrong and unactionable.
is CalDavDiscovery.Outcome.Failed -> backToServer(outcome.cause)
is CalDavDiscovery.Outcome.NotCalDav -> backToServer(outcome.cause)
else -> backToServer(CalDavDiscovery.Outcome.Cause.NO_CALENDARS)
} }
return@launch return@launch
} }
@@ -136,6 +136,40 @@ class AddAccountViewModelTest {
.isEqualTo("https://dav.example.com/") .isEqualTo("https://dav.example.com/")
} }
@Test
fun `a discovery failure after approval reports what actually failed`() =
runTest(dispatcher) {
// The first outcome is the unauthenticated probe that sends us
// into the browser flow; the second is the discovery that runs
// once the credentials come back.
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(
listOf("cloud.example.com"),
)
gateway.loginFlow = flow()
gateway.pollResults += NextcloudLoginFlow.PollResult.Approved(
NextcloudLoginFlow.Credentials(
server = "https://cloud.example.com/".toHttpUrl(),
loginName = "me",
appPassword = "app-pw",
),
)
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.Failed(
CalDavDiscovery.Outcome.Cause.UNREACHABLE,
"nextcloud: nodename nor servname provided",
)
val vm = viewModel()
vm.onServerInputChanged("https://cloud.example.com/")
vm.onServerSubmitted()
advanceUntilIdle()
// ⚠️ Collapsing this to NO_CALENDARS tells someone with a DNS
// failure that their account holds no task lists — wrong, and
// nothing they can act on.
val step = vm.state.value.step as AddAccountStep.EnterServer
assertThat(step.error).isEqualTo(CalDavDiscovery.Outcome.Cause.UNREACHABLE)
}
@Test @Test
fun `the login URL is handed to the browser exactly once`() = runTest(dispatcher) { fun `the login URL is handed to the browser exactly once`() = runTest(dispatcher) {
gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList()) gateway.discoveryOutcomes += CalDavDiscovery.Outcome.NeedsAuthentication(emptyList())