diff --git a/Sources/CodeCaps/MonitorModel.swift b/Sources/CodeCaps/MonitorModel.swift index 13ae50a..8ffb5f1 100644 --- a/Sources/CodeCaps/MonitorModel.swift +++ b/Sources/CodeCaps/MonitorModel.swift @@ -395,6 +395,7 @@ final class MonitorModel: ObservableObject { private enum PushOutcome { case success(QuotaPublishResult) case failure(String) + case noPublishableWindows case cancelled } @@ -1591,6 +1592,8 @@ final class MonitorModel: ObservableObject { return (true, result.message) case let .failure(message): return (false, message) + case .noPublishableWindows: + return (false, "No quota windows with a known remaining percentage to publish.") case .cancelled: return (false, "Push cancelled because sharing was disabled or its settings changed.") } @@ -1660,6 +1663,13 @@ final class MonitorModel: ObservableObject { self.isCurrentPushSettings(generation, endpoint: targetEndpoint, format: targetFormat) else { return .cancelled } + // Unknown/credits-only readings are a valid local state, not + // a failed sync. The publisher rejects this batch before any + // request; preserve prior status and never claim an ACK. + if let publishError = error as? QuotaPublisherError, + publishError == .noPublishableWindows { + return .noPublishableWindows + } let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription self.lastSyncStatus = "Error: \(message)" self.lastSyncError = message diff --git a/Sources/QuotaCore/QuotaPublisher.swift b/Sources/QuotaCore/QuotaPublisher.swift index a9d0bda..7891c08 100644 --- a/Sources/QuotaCore/QuotaPublisher.swift +++ b/Sources/QuotaCore/QuotaPublisher.swift @@ -29,6 +29,7 @@ public struct QuotaPublishResult: Sendable, Equatable { public enum QuotaPublisherError: Error, Equatable, Sendable, LocalizedError { case invalidEndpoint case emptyWindows + case noPublishableWindows case unauthorized case httpStatus(Int, String?) case timedOut @@ -41,6 +42,8 @@ public enum QuotaPublisherError: Error, Equatable, Sendable, LocalizedError { return "The sync endpoint URL is invalid or not allowed (must be HTTPS or localhost)." case .emptyWindows: return "No quota windows to publish." + case .noPublishableWindows: + return "No quota windows with a known remaining percentage to publish." case .unauthorized: return "Unauthorized (HTTP 401)." + sentenceGap + "Check your Ingest Token." case let .httpStatus(status, detail): @@ -113,14 +116,21 @@ public actor QuotaPublisher { } let bodyData: Data + let eventCount: Int let now = Date() let occurredAtIso = ISO8601DateFormatter().string(from: now) switch format { case .usageMonitorV2: - bodyData = try buildUsageMonitorV2Payload(windows: windows, occurredAtIso: occurredAtIso, machineName: machineName) + let payload = try makeUsageMonitorV2Payload(windows: windows, occurredAtIso: occurredAtIso, machineName: machineName) + guard payload.eventCount > 0 else { + throw QuotaPublisherError.noPublishableWindows + } + bodyData = payload.data + eventCount = payload.eventCount case .genericWebhook: bodyData = try buildGenericWebhookPayload(windows: windows, occurredAtIso: occurredAtIso, machineName: machineName) + eventCount = windows.count } var request = URLRequest(url: endpoint) @@ -144,6 +154,11 @@ public actor QuotaPublisher { } if (200..<300).contains(http.statusCode) { + if format == .usageMonitorV2 { + let acknowledgment = try validateUsageMonitorAcknowledgment(data, eventCount: eventCount) + let message = "Synced \(acknowledgment.received) quotas (\(acknowledgment.persisted) persisted, \(acknowledgment.duplicates) duplicates, \(acknowledgment.pruned) pruned)." + return QuotaPublishResult(statusCode: http.statusCode, count: eventCount, message: message) + } var message = "Pushed \(windows.count) quota windows successfully." if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { if let persisted = json["persisted"] as? Int, let received = json["received"] as? Int { @@ -170,7 +185,54 @@ public actor QuotaPublisher { } } + private struct UsageMonitorAcknowledgment: Decodable { + let ok: Bool + let schemaVersion: Int + let received: Int + let persisted: Int + let duplicates: Int + let pruned: Int + let rejected: Int + } + + private func validateUsageMonitorAcknowledgment(_ data: Data, eventCount: Int) throws -> UsageMonitorAcknowledgment { + // Typed decoding rejects booleans, strings, fractions and out-of-range + // counters instead of Foundation's permissive NSNumber bridging. + guard let acknowledgment = try? JSONDecoder().decode(UsageMonitorAcknowledgment.self, from: data), + acknowledgment.ok, acknowledgment.schemaVersion == 2 else { + throw QuotaPublisherError.serverError("Usage Monitor returned an invalid v2 acknowledgment.") + } + let counts = [acknowledgment.persisted, acknowledgment.duplicates, acknowledgment.pruned, acknowledgment.rejected] + guard acknowledgment.received >= 0, counts.allSatisfy({ $0 >= 0 }) else { + throw QuotaPublisherError.serverError("Usage Monitor returned invalid acknowledgment counts.") + } + guard acknowledgment.received == eventCount else { + throw QuotaPublisherError.serverError("Usage Monitor acknowledged \(acknowledgment.received) quota events; the request contained \(eventCount).") + } + // Subtract from the bounded sent count, so hostile counts cannot + // overflow while checking the complete disposition of the batch. + var unaccounted = acknowledgment.received + for count in counts { + guard count <= unaccounted else { + throw QuotaPublisherError.serverError("Usage Monitor acknowledgment counts do not add up to the received total.") + } + unaccounted -= count + } + guard unaccounted == 0 else { + throw QuotaPublisherError.serverError("Usage Monitor acknowledgment counts do not add up to the received total.") + } + guard acknowledgment.rejected == 0 else { + // Include only validated counters, never raw server error details. + throw QuotaPublisherError.serverError("Usage Monitor rejected \(acknowledgment.rejected) of \(acknowledgment.received) quota events (\(acknowledgment.persisted) persisted, \(acknowledgment.duplicates) duplicates, \(acknowledgment.pruned) pruned).") + } + return acknowledgment + } + public nonisolated func buildUsageMonitorV2Payload(windows: [QuotaWindow], occurredAtIso: String, machineName: String? = nil, producerInstanceId: String = QuotaPublisher.producerInstanceId) throws -> Data { + try makeUsageMonitorV2Payload(windows: windows, occurredAtIso: occurredAtIso, machineName: machineName, producerInstanceId: producerInstanceId).data + } + + private nonisolated func makeUsageMonitorV2Payload(windows: [QuotaWindow], occurredAtIso: String, machineName: String?, producerInstanceId: String = QuotaPublisher.producerInstanceId) throws -> (data: Data, eventCount: Int) { let machine = machineName ?? Self.machineName let events: [[String: Any]] = windows.compactMap { window in guard let remaining = window.boundedRemainingPercent ?? window.remainingPercent else { return nil } @@ -241,7 +303,7 @@ public actor QuotaPublisher { "producerInstanceId": producerInstanceId, "events": events ] - return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys]) + return (try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys]), events.count) } public nonisolated func buildGenericWebhookPayload(windows: [QuotaWindow], occurredAtIso: String, machineName: String?, producerInstanceId: String = QuotaPublisher.producerInstanceId) throws -> Data { diff --git a/Tests/CodeCapsTests/DataModeTests.swift b/Tests/CodeCapsTests/DataModeTests.swift index b3ed041..7fcdd4b 100644 --- a/Tests/CodeCapsTests/DataModeTests.swift +++ b/Tests/CodeCapsTests/DataModeTests.swift @@ -169,6 +169,78 @@ final class DataModeTests: XCTestCase { XCTAssertEqual(published, 0) } + func testAutomaticPushWithNoPublishableWindowsIsNeutral() async { + let model = noPublishableModel() + var attempts = 0 + model.syncTokenReadForTesting = { UUID().uuidString } + model.pushForTesting = { windows, _, _, _ in + attempts += 1 + XCTAssertTrue(windows.allSatisfy { $0.remainingPercent == nil }) + throw QuotaPublisherError.noPublishableWindows + } + + model.refresh() + let finished = await waitForRefreshToFinish(model) + + XCTAssertTrue(finished) + XCTAssertEqual(attempts, 1) + XCTAssertNil(model.lastSyncTime) + XCTAssertNil(model.lastSyncStatus) + XCTAssertNil(model.lastSyncError) + XCTAssertFalse(model.isSyncing) + } + + func testManualPushWithNoPublishableWindowsReportsNeutralNoOp() async { + let model = noPublishableModel() + var attempts = 0 + model.pushForTesting = { windows, _, _, _ in + attempts += 1 + XCTAssertTrue(windows.allSatisfy { $0.remainingPercent == nil }) + throw QuotaPublisherError.noPublishableWindows + } + + let outcome = await model.testAndPushSync(token: UUID().uuidString) + + XCTAssertFalse(outcome.success, "A no-op must not claim a delivery receipt") + XCTAssertEqual(outcome.message, "No quota windows with a known remaining percentage to publish.") + XCTAssertEqual(attempts, 1) + XCTAssertNil(model.lastSyncTime) + XCTAssertNil(model.lastSyncStatus) + XCTAssertNil(model.lastSyncError) + XCTAssertFalse(model.isSyncing) + } + + func testManualPushKeepsAcknowledgmentFailuresVisible() async { + let model = noPublishableModel() + model.pushForTesting = { _, _, _, _ in + throw QuotaPublisherError.serverError("Invalid acknowledgment.") + } + + let outcome = await model.testAndPushSync(token: UUID().uuidString) + + XCTAssertFalse(outcome.success) + XCTAssertEqual(outcome.message, "Server error: Invalid acknowledgment.") + XCTAssertEqual(model.lastSyncStatus, "Error: Server error: Invalid acknowledgment.") + XCTAssertEqual(model.lastSyncError, "Server error: Invalid acknowledgment.") + XCTAssertNil(model.lastSyncTime) + XCTAssertFalse(model.isSyncing) + } + + private func noPublishableModel() -> MonitorModel { + defaults.set(true, forKey: "localEnabled") + defaults.set(false, forKey: "serverEnabled") + defaults.set(true, forKey: "syncEnabled") + defaults.set("https://quota.example.test/api/ingest/usage", forKey: "syncEndpoint") + defaults.set(QuotaSyncFormat.usageMonitorV2.rawValue, forKey: "syncFormat") + let model = MonitorModel(defaults: defaults) + model.skipsSnapshotIOForTesting = true + var unknown = window("credits-only") + unknown.remainingPercent = nil + unknown.remainingUnknown = true + model.localResultForTesting = LocalQuotaResult(windows: [unknown]) + return model + } + private func waitForRefreshToFinish(_ model: MonitorModel) async -> Bool { for _ in 0..<10_000 where model.isRefreshing { try? await Task.sleep(nanoseconds: 100_000) diff --git a/Tests/QuotaCoreTests/QuotaPublisherTests.swift b/Tests/QuotaCoreTests/QuotaPublisherTests.swift index dd69b3c..8c6ca05 100644 --- a/Tests/QuotaCoreTests/QuotaPublisherTests.swift +++ b/Tests/QuotaCoreTests/QuotaPublisherTests.swift @@ -87,6 +87,65 @@ final class QuotaPublisherTests: XCTestCase { ] } + private func acknowledgment( + received: Int = 2, persisted: Int = 2, duplicates: Int = 0, + pruned: Int = 0, rejected: Int = 0 + ) -> [String: Any] { + [ + "ok": true, "schemaVersion": 2, "received": received, + "persisted": persisted, "duplicates": duplicates, + "pruned": pruned, "rejected": rejected + ] + } + + private func requestEvents(_ request: URLRequest) throws -> [[String: Any]] { + let data: Data + if let body = request.httpBody { + data = body + } else { + let stream = try XCTUnwrap(request.httpBodyStream) + stream.open() + defer { stream.close() } + var body = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = stream.read(&buffer, maxLength: buffer.count) + if count < 0 { throw stream.streamError ?? URLError(.cannotDecodeRawData) } + if count == 0 { break } + body.append(contentsOf: buffer.prefix(count)) + } + data = body + } + let payload = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + return try XCTUnwrap(payload["events"] as? [[String: Any]]) + } + + private func assertInvalidAcknowledgment( + _ data: Data, statusCode: Int = 200, message: String? = nil, + file: StaticString = #filePath, line: UInt = #line + ) async { + MockSyncProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + return (response, data) + } + let publisher = QuotaPublisher(urlProtocolClasses: [MockSyncProtocol.self]) + do { + _ = try await publisher.publish(windows: makeSampleWindows(), to: URL(string: "https://usage.example.com/api/ingest/usage")!) + XCTFail("Expected acknowledgment failure", file: file, line: line) + } catch let error as QuotaPublisherError { + guard case let .serverError(detail) = error else { + XCTFail("Unexpected publisher error: \(error)", file: file, line: line) + return + } + if let message { + XCTAssertTrue(detail.contains(message), "Unexpected acknowledgment error: \(detail)", file: file, line: line) + } + XCTAssertFalse(detail.contains("private-server-detail"), file: file, line: line) + } catch { + XCTFail("Unexpected error: \(error)", file: file, line: line) + } + } + func testBuildUsageMonitorV2Payload() throws { let publisher = QuotaPublisher() let windows = makeSampleWindows() @@ -215,13 +274,13 @@ final class QuotaPublisherTests: XCTestCase { } func testPublishHandlesSuccess() async throws { + let responseData = try JSONSerialization.data(withJSONObject: acknowledgment()) MockSyncProtocol.requestHandler = { request in XCTAssertEqual(request.httpMethod, "POST") XCTAssertEqual(request.value(forHTTPHeaderField: "Authorization"), "Bearer test-ingest-token") XCTAssertEqual(request.value(forHTTPHeaderField: "x-usage-ingest-token"), "test-ingest-token") let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])! - let responseData = "{\"received\": 2, \"persisted\": 2}".data(using: .utf8)! return (response, responseData) } @@ -232,7 +291,183 @@ final class QuotaPublisherTests: XCTestCase { let result = try await publisher.publish(windows: windows, to: url, token: "test-ingest-token") XCTAssertEqual(result.statusCode, 200) XCTAssertEqual(result.count, 2) - XCTAssertTrue(result.message.contains("Synced 2 quotas (2 persisted)")) + XCTAssertEqual(result.message, "Synced 2 quotas (2 persisted, 0 duplicates, 0 pruned).") + } + + func testPublishRejectsMalformedSuccessfulResponses() async { + for body in ["", "private-server-detail", "{", "[]", "null", "{}"] { + await assertInvalidAcknowledgment(Data(body.utf8), message: "invalid v2 acknowledgment") + } + await assertInvalidAcknowledgment(Data(), statusCode: 204) + } + + func testPublishRequiresTypedV2SuccessEnvelope() async throws { + for key in ["ok", "schemaVersion"] { + var missing = acknowledgment() + missing.removeValue(forKey: key) + await assertInvalidAcknowledgment(try JSONSerialization.data(withJSONObject: missing)) + } + let invalidFields: [(String, Any)] = [ + ("ok", false), ("ok", 1), ("ok", "true"), ("ok", NSNull()), + ("schemaVersion", 1), ("schemaVersion", 3), ("schemaVersion", true), + ("schemaVersion", "2"), ("schemaVersion", 2.5), ("schemaVersion", NSNull()) + ] + for (key, value) in invalidFields { + var invalid = acknowledgment() + invalid[key] = value + await assertInvalidAcknowledgment(try JSONSerialization.data(withJSONObject: invalid)) + } + } + + func testPublishRejectsMissingOrInvalidCounts() async throws { + for key in ["received", "persisted", "duplicates", "pruned", "rejected"] { + var missing = acknowledgment() + missing.removeValue(forKey: key) + await assertInvalidAcknowledgment(try JSONSerialization.data(withJSONObject: missing)) + let invalidValues: [Any] = [true, false, "0", -1, 0.5, NSNull(), [] as [Int], [:] as [String: Int]] + for value in invalidValues { + var invalid = acknowledgment() + invalid[key] = value + await assertInvalidAcknowledgment(try JSONSerialization.data(withJSONObject: invalid)) + } + } + let outOfRange = "{\"ok\":true,\"schemaVersion\":2,\"received\":2,\"persisted\":9223372036854775808,\"duplicates\":0,\"pruned\":0,\"rejected\":0}" + await assertInvalidAcknowledgment(Data(outOfRange.utf8)) + } + + func testPublishRejectsInconsistentCountsWithoutOverflow() async throws { + for invalid in [ + acknowledgment(received: 1, persisted: 1), + acknowledgment(received: 3, persisted: 3), + acknowledgment(persisted: 1), + acknowledgment(persisted: 2, duplicates: 1), + acknowledgment(persisted: Int.max, duplicates: Int.max) + ] { + await assertInvalidAcknowledgment(try JSONSerialization.data(withJSONObject: invalid)) + } + } + + func testPublishReportsPartialAndFullRejectionWithoutServerDetails() async throws { + for statusCode in [200, 202] { + for rejected in [1, 2] { + var response = acknowledgment(persisted: 2 - rejected, rejected: rejected) + response["rejections"] = [["index": 0, "reason": "private-server-detail"]] + await assertInvalidAcknowledgment( + try JSONSerialization.data(withJSONObject: response), + statusCode: statusCode, + message: "rejected \(rejected) of 2 quota events" + ) + } + } + } + + func testPublishAcceptsDuplicateAndPrunedDispositions() async throws { + for (persisted, duplicates, pruned) in [(0, 2, 0), (0, 0, 2), (1, 1, 0), (1, 0, 1), (0, 1, 1)] { + let data = try JSONSerialization.data(withJSONObject: acknowledgment(persisted: persisted, duplicates: duplicates, pruned: pruned)) + MockSyncProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 202, httpVersion: nil, headerFields: nil)! + return (response, data) + } + let publisher = QuotaPublisher(urlProtocolClasses: [MockSyncProtocol.self]) + let result = try await publisher.publish(windows: makeSampleWindows(), to: URL(string: "https://usage.example.com/api/ingest/usage")!) + XCTAssertEqual(result.statusCode, 202) + XCTAssertEqual(result.count, 2) + XCTAssertEqual(result.message, "Synced 2 quotas (\(persisted) persisted, \(duplicates) duplicates, \(pruned) pruned).") + } + } + + func testPublishRetryPreservesEventIdsAndAcceptsDuplicates() async throws { + // The first request may have persisted even though its ACK is invalid. + let invalidFirstResponse = Data("{\"ok\":true}".utf8) + let duplicates = try JSONSerialization.data(withJSONObject: acknowledgment(persisted: 0, duplicates: 2)) + var submittedIds: [[String]] = [] + MockSyncProtocol.requestHandler = { request in + let events = try self.requestEvents(request) + submittedIds.append(try events.map { try XCTUnwrap($0["eventId"] as? String) }) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, submittedIds.count == 1 ? invalidFirstResponse : duplicates) + } + let publisher = QuotaPublisher(urlProtocolClasses: [MockSyncProtocol.self]) + let windows = makeSampleWindows() + let endpoint = URL(string: "https://usage.example.com/api/ingest/usage")! + do { + _ = try await publisher.publish(windows: windows, to: endpoint) + XCTFail("Expected the invalid first ACK to fail") + } catch let error as QuotaPublisherError { + XCTAssertEqual(error, .serverError("Usage Monitor returned an invalid v2 acknowledgment.")) + } + let retry = try await publisher.publish(windows: windows, to: endpoint) + XCTAssertEqual(retry.count, 2) + XCTAssertTrue(retry.message.contains("2 duplicates")) + XCTAssertEqual(submittedIds.count, 2) + XCTAssertEqual(submittedIds.first?.count, 2) + XCTAssertEqual(submittedIds.first, submittedIds.last) + } + + func testPublishValidatesActualFilteredEventCount() async throws { + var windows = makeSampleWindows() + windows[1].remainingPercent = nil + windows[1].remainingUnknown = true + let data = try JSONSerialization.data(withJSONObject: acknowledgment(received: 1, persisted: 1)) + MockSyncProtocol.requestHandler = { request in + XCTAssertEqual(try self.requestEvents(request).count, 1) + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, data) + } + let publisher = QuotaPublisher(urlProtocolClasses: [MockSyncProtocol.self]) + let endpoint = URL(string: "https://usage.example.com/api/ingest/usage")! + let result = try await publisher.publish(windows: windows, to: endpoint) + XCTAssertEqual(result.count, 1) + XCTAssertTrue(result.message.contains("Synced 1 quotas")) + + let inflated = try JSONSerialization.data(withJSONObject: acknowledgment()) + MockSyncProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + return (response, inflated) + } + do { + _ = try await publisher.publish(windows: windows, to: endpoint) + XCTFail("Expected the ACK to match emitted events, not input windows") + } catch let error as QuotaPublisherError { + XCTAssertEqual(error, .serverError("Usage Monitor acknowledged 2 quota events; the request contained 1.")) + } + } + + func testPublishWithNoPublishableEventsFailsBeforeRequest() async { + var windows = makeSampleWindows() + for index in windows.indices { + windows[index].remainingPercent = nil + windows[index].remainingUnknown = true + } + MockSyncProtocol.requestHandler = { _ in + XCTFail("An empty event batch must not be sent") + throw URLError(.badServerResponse) + } + let publisher = QuotaPublisher(urlProtocolClasses: [MockSyncProtocol.self]) + do { + _ = try await publisher.publish(windows: windows, to: URL(string: "https://usage.example.com/api/ingest/usage")!) + XCTFail("Expected noPublishableWindows error") + } catch let error as QuotaPublisherError { + XCTAssertEqual(error, .noPublishableWindows) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testGenericWebhookKeepsHTTP2xxSuccessSemantics() async throws { + for (statusCode, body) in [(204, ""), (200, "not-json"), (200, "{\"ok\":false}")] { + MockSyncProtocol.requestHandler = { request in + let response = HTTPURLResponse(url: request.url!, statusCode: statusCode, httpVersion: nil, headerFields: nil)! + return (response, Data(body.utf8)) + } + var windows = makeSampleWindows() + windows[1].remainingPercent = nil + let publisher = QuotaPublisher(urlProtocolClasses: [MockSyncProtocol.self]) + let result = try await publisher.publish(windows: windows, to: URL(string: "https://webhook.example.com/quotas")!, format: .genericWebhook) + XCTAssertEqual(result.statusCode, statusCode) + XCTAssertEqual(result.count, 2) + XCTAssertEqual(result.message, "Pushed 2 quota windows successfully.") + } } func testPublishHandlesUnauthorized() async {