fix(android): require server clock for repurchase cutoff (#367)
This commit is contained in:
@@ -28,19 +28,33 @@ class RepurchaseBatch internal constructor(items: List<PurchaseHistoryItem>) {
|
||||
}
|
||||
|
||||
/** Read-only discovery. The callback must use the current device's authenticated history endpoint.
|
||||
*
|
||||
* [nowMillis] MUST return the trusted server Date time from the response to that fetch, and throw
|
||||
* when it is missing or invalid. Never fall back to the phone clock or a previous response's Date:
|
||||
* the server applies the rolling 30-day cutoff, and a slow phone could accept a truncated batch.
|
||||
* Tests may inject a synthetic server clock. It is read only after each successful page response.
|
||||
*
|
||||
* Offset overlap is tolerated; a changed head is rejected. This is not a snapshot guarantee for
|
||||
* arbitrary concurrent deletion, reassignment or timestamp edits on the server.
|
||||
*/
|
||||
class RepurchaseBatchDiscovery(
|
||||
private val fetchPage: (RepurchaseHistoryQuery) -> HistoryPage<PurchaseHistoryItem>,
|
||||
private val nowMillis: () -> Long = System::currentTimeMillis,
|
||||
private val nowMillis: () -> Long,
|
||||
private val maxPages: Int = 200,
|
||||
) {
|
||||
init { require(maxPages > 0) }
|
||||
|
||||
fun discover(): RepurchaseBatch {
|
||||
val startedAt = nowMillis()
|
||||
var latestServerTime = 0L
|
||||
fun readWithServerTime(pageNumber: Int): HistoryPage<PurchaseHistoryItem> {
|
||||
val page = readPage(pageNumber)
|
||||
val serverTime = try { nowMillis() } catch (_: Exception) {
|
||||
refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
|
||||
}
|
||||
if (serverTime <= 0) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
|
||||
latestServerTime = maxOf(latestServerTime, serverTime)
|
||||
return page
|
||||
}
|
||||
val seen = linkedMapOf<Long, Entry>()
|
||||
val batch = mutableListOf<PurchaseHistoryItem>()
|
||||
var head: Long? = null
|
||||
@@ -49,7 +63,7 @@ class RepurchaseBatchDiscovery(
|
||||
var foundBoundary = false
|
||||
var reachedEnd = false
|
||||
for (pageNumber in 1..maxPages) {
|
||||
val page = readPage(pageNumber)
|
||||
val page = readWithServerTime(pageNumber)
|
||||
if (originalTotal == null) originalTotal = page.total
|
||||
else if (page.total != originalTotal) refuse(RepurchaseBatchFailure.LIST_CHANGED)
|
||||
val entries = parseOrdered(page.items)
|
||||
@@ -82,13 +96,14 @@ class RepurchaseBatchDiscovery(
|
||||
}
|
||||
if (!foundBoundary && !reachedEnd) refuse(RepurchaseBatchFailure.PAGE_LIMIT)
|
||||
|
||||
val finalPage = readPage(1)
|
||||
val finalPage = readWithServerTime(1)
|
||||
val finalHead = parseOrdered(finalPage.items).firstOrNull()?.item?.taskId
|
||||
if (finalHead != head || finalPage.total != originalTotal) refuse(RepurchaseBatchFailure.LIST_CHANGED)
|
||||
if (reachedEnd && previous != null) {
|
||||
// The API applies a rolling 30-day range, not a midnight-based date filter.
|
||||
// Use the later clock reading because the floor can advance during pagination.
|
||||
val floorMillis = maxOf(startedAt, nowMillis()) - DAYS * 24L * 60 * 60 * 1000
|
||||
// HTTP Date has whole-second precision; include its possible fractional second.
|
||||
// Preserve the latest observed time if a later response's Date moves backwards.
|
||||
val floorMillis = latestServerTime + 1000 - DAYS * 24L * 60 * 60 * 1000
|
||||
val floorSeconds = floorMillis / 1000 - if (floorMillis < 0 && floorMillis % 1000 != 0L) 1 else 0
|
||||
val floor = Timestamp(floorSeconds, ((floorMillis - floorSeconds * 1000) * 1_000_000).toInt())
|
||||
if (!previous.time.moreThanSixtySecondsAfter(floor)) refuse(RepurchaseBatchFailure.INCOMPLETE_BATCH)
|
||||
|
||||
@@ -96,10 +96,60 @@ class RepurchaseBatchTest {
|
||||
|
||||
@Test fun nearThirtyDayCutoffCannotProveBatchComplete() {
|
||||
val days30 = 30L * 24 * 60 * 60
|
||||
listOf(days30, days30 - 60, days30 + 1).forEach { age ->
|
||||
listOf(days30, days30 - 60, days30 - 61, days30 + 1).forEach { age ->
|
||||
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) { discover(listOf(item(1, age))) }
|
||||
}
|
||||
assertEquals(1, discover(listOf(item(1, days30 - 61))).items.size)
|
||||
assertEquals(1, discover(listOf(item(1, days30 - 62))).items.size)
|
||||
}
|
||||
|
||||
@Test fun usesServerResponseClockEvenWhenPhoneIsFiveMinutesSlow() {
|
||||
val phoneNow = now
|
||||
val serverNow = phoneNow + 5 * 60 * 1000
|
||||
val ageFromPhoneSeconds = 30L * 24 * 60 * 60 - 5 * 60 - 30
|
||||
var responseTime: Long? = null
|
||||
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
|
||||
RepurchaseBatchDiscovery({ q ->
|
||||
responseTime = serverNow
|
||||
HistoryPage(listOf(item(1, ageFromPhoneSeconds)), 1, q.page, 50)
|
||||
}, { checkNotNull(responseTime) }).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun responseTimeIsNotRequiredBeforeFirstFetch() {
|
||||
var responseTime: Long? = null
|
||||
val batch = RepurchaseBatchDiscovery({ q ->
|
||||
responseTime = now
|
||||
HistoryPage(listOf(item(1)), 1, q.page, 50)
|
||||
}, { checkNotNull(responseTime) }).discover()
|
||||
assertEquals(1, batch.items.size)
|
||||
}
|
||||
|
||||
@Test fun missingServerResponseClockRefusesWithoutPhoneClockFallback() {
|
||||
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
|
||||
RepurchaseBatchDiscovery({ q -> HistoryPage(listOf(item(1)), 1, q.page, 50) },
|
||||
{ error("No server Date") }).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun retainsLatestObservedServerClockWhenLaterResponseTimeRegresses() {
|
||||
var responseTime = now
|
||||
var calls = 0
|
||||
refused(RepurchaseBatchFailure.INCOMPLETE_BATCH) {
|
||||
RepurchaseBatchDiscovery({ q ->
|
||||
responseTime = if (++calls == 1) now + 300_000 else now
|
||||
HistoryPage(listOf(item(1, 30L * 24 * 60 * 60 - 330)), 1, q.page, 50)
|
||||
}, { responseTime }).discover()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun exactlyFiftyRecordsCanEstablishTrustworthyEnd() {
|
||||
val requested = mutableListOf<Int>()
|
||||
val batch = RepurchaseBatchDiscovery({ q ->
|
||||
requested += q.page
|
||||
HistoryPage((50L downTo 1).map { item(it, 50 - it) }, 50, q.page, 50)
|
||||
}, { now }).discover()
|
||||
assertEquals(50, batch.items.size)
|
||||
assertEquals(listOf(1, 1), requested)
|
||||
}
|
||||
|
||||
@Test fun latestSuccessfulBatchDoesNotFallBackToOlderFailures() {
|
||||
|
||||
Reference in New Issue
Block a user