From 1dbaa949b5cb41749fcf8e2ac6c41bdb56b319fa Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Tue, 14 Jul 2026 16:59:09 +0200 Subject: [PATCH 1/5] WIP Signed-off-by: Milen Pivchev --- .../Governance/NKGovernanceEntityLabels.swift | 25 +++ .../Models/Governance/NKGovernanceLabel.swift | 39 ++++ .../Governance/NKGovernanceLabelScope.swift | 10 + .../Governance/NKGovernanceLabelType.swift | 11 + .../NextcloudKit+Governance.swift | 189 ++++++++++++++++++ .../GovernanceUnitTests.swift | 78 ++++++++ .../GovernanceAvailableLabelsMock.json | 27 +++ .../GovernanceEntityLabelsEmptyMock.json | 13 ++ .../Resources/GovernanceEntityLabelsMock.json | 29 +++ .../GovernanceGenericResponseMock.json | 12 ++ 10 files changed, 433 insertions(+) create mode 100644 Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift create mode 100644 Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift create mode 100644 Sources/NextcloudKit/Models/Governance/NKGovernanceLabelScope.swift create mode 100644 Sources/NextcloudKit/Models/Governance/NKGovernanceLabelType.swift create mode 100644 Sources/NextcloudKit/NextcloudKit+Governance.swift create mode 100644 Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift create mode 100644 Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableLabelsMock.json create mode 100644 Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsEmptyMock.json create mode 100644 Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json create mode 100644 Tests/NextcloudKitUnitTests/Resources/GovernanceGenericResponseMock.json diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift new file mode 100644 index 00000000..249b0e76 --- /dev/null +++ b/Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public struct NKGovernanceEntityLabels: Codable, Sendable, Equatable, Hashable { + public let sensitivity: NKGovernanceLabel? + public let retention: [NKGovernanceLabel] + + public init(sensitivity: NKGovernanceLabel?, retention: [NKGovernanceLabel]) { + self.sensitivity = sensitivity + self.retention = retention + } + + enum CodingKeys: String, CodingKey { + case sensitivity, retention + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sensitivity = try container.decodeIfPresent(NKGovernanceLabel.self, forKey: .sensitivity) + retention = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .retention) ?? [] + } +} diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift new file mode 100644 index 00000000..f1cd8b62 --- /dev/null +++ b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public struct NKGovernanceLabel: Codable, Sendable, Equatable, Hashable { + public let id: String + public let name: String + public let priority: Int + public let description: String + public let color: String + public let scopes: [NKGovernanceLabelScope] + + public init(id: String, name: String, priority: Int, description: String, color: String, scopes: [NKGovernanceLabelScope]) { + self.id = id + self.name = name + self.priority = priority + self.description = description + self.color = color + self.scopes = scopes + } + + enum CodingKeys: String, CodingKey { + case id, name, priority, description, color, scopes + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + priority = try container.decode(Int.self, forKey: .priority) + description = try container.decode(String.self, forKey: .description) + color = try container.decode(String.self, forKey: .color) + // Tolerate unknown scope values rather than failing the whole decode. + let rawScopes = try container.decodeIfPresent([String].self, forKey: .scopes) ?? [] + scopes = rawScopes.compactMap(NKGovernanceLabelScope.init(rawValue:)) + } +} diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelScope.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelScope.swift new file mode 100644 index 00000000..d617b62c --- /dev/null +++ b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelScope.swift @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public enum NKGovernanceLabelScope: String, Codable, Sendable, Equatable, Hashable { + case files = "FILES" + case mails = "MAILS" +} diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelType.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelType.swift new file mode 100644 index 00000000..2c4f2ce3 --- /dev/null +++ b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelType.swift @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public enum NKGovernanceLabelType: String, Sendable, Equatable, Hashable { + case sensitivity = "SENSITIVITY" + case retention = "RETENTION" + case hold = "HOLD" +} diff --git a/Sources/NextcloudKit/NextcloudKit+Governance.swift b/Sources/NextcloudKit/NextcloudKit+Governance.swift new file mode 100644 index 00000000..f8576e7b --- /dev/null +++ b/Sources/NextcloudKit/NextcloudKit+Governance.swift @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import Alamofire + +public extension NextcloudKit { + /// Returns the sensitivity labels the user may apply to an entity. + func getGovernanceAvailableSensitivityLabels(entityType: String = "FILES", + entityId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { + await getGovernanceAvailableLabels(entityType: entityType, entityId: entityId, labelKind: "sensitivity", account: account, options: options, taskHandler: taskHandler) + } + + /// Returns the retention labels the user may apply to an entity. + func getGovernanceAvailableRetentionLabels(entityType: String = "FILES", + entityId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { + await getGovernanceAvailableLabels(entityType: entityType, entityId: entityId, labelKind: "retention", account: account, options: options, taskHandler: taskHandler) + } + + /// Returns the hold labels the user may apply to an entity. + func getGovernanceAvailableHoldLabels(entityType: String = "FILES", + entityId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { + await getGovernanceAvailableLabels(entityType: entityType, entityId: entityId, labelKind: "hold", account: account, options: options, taskHandler: taskHandler) + } + + /// Returns all labels applied to an entity, grouped by type and filtered to those visible to the user. + func getGovernanceLabels(entityType: String = "FILES", + entityId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, labels: NKGovernanceEntityLabels?, responseData: AFDataResponse?, error: NKError) { + await withCheckedContinuation { continuation in + let endpoint = governancePath(entityType: entityType, entityId: entityId) + "?format=json" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { + continuation.resume(returning: (account, nil, nil, .urlError)) + } + } + + nkSession.sessionData.request(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { + continuation.resume(returning: (account, nil, response, error)) + } + case .success(let data): + if let result = try? JSONDecoder().decode(GovernanceOCS.self, from: data) { + options.queue.async { + continuation.resume(returning: (account, result.ocs.data, response, .success)) + } + } else { + options.queue.async { + continuation.resume(returning: (account, nil, response, .invalidData)) + } + } + } + } + } + } + + /// Applies a label to an entity. Only one label per type may be active (except hold, which allows multiple). + func setGovernanceLabel(entityType: String = "FILES", + entityId: String, + labelType: NKGovernanceLabelType, + labelId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, responseData: AFDataResponse?, error: NKError) { + await applyGovernanceLabel(method: .post, entityType: entityType, entityId: entityId, labelType: labelType, labelId: labelId, account: account, options: options, taskHandler: taskHandler) + } + + /// Removes a label from an entity. + func removeGovernanceLabel(entityType: String = "FILES", + entityId: String, + labelType: NKGovernanceLabelType, + labelId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, responseData: AFDataResponse?, error: NKError) { + await applyGovernanceLabel(method: .delete, entityType: entityType, entityId: entityId, labelType: labelType, labelId: labelId, account: account, options: options, taskHandler: taskHandler) + } + + private func governancePath(entityType: String, entityId: String) -> String { + "ocs/v2.php/apps/governance/v1/labels/\(entityType)/\(entityId)" + } + + private func getGovernanceAvailableLabels(entityType: String, + entityId: String, + labelKind: String, + account: String, + options: NKRequestOptions, + taskHandler: @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { + await withCheckedContinuation { continuation in + let endpoint = governancePath(entityType: entityType, entityId: entityId) + "/\(labelKind)/available?format=json" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { + continuation.resume(returning: (account, nil, nil, .urlError)) + } + } + + nkSession.sessionData.request(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { + continuation.resume(returning: (account, nil, response, error)) + } + case .success(let data): + if let result = try? JSONDecoder().decode(GovernanceOCS<[NKGovernanceLabel]>.self, from: data) { + options.queue.async { + continuation.resume(returning: (account, result.ocs.data, response, .success)) + } + } else { + options.queue.async { + continuation.resume(returning: (account, nil, response, .invalidData)) + } + } + } + } + } + } + + private func applyGovernanceLabel(method: HTTPMethod, + entityType: String, + entityId: String, + labelType: NKGovernanceLabelType, + labelId: String, + account: String, + options: NKRequestOptions, + taskHandler: @escaping (_ task: URLSessionTask) -> Void + ) async -> (account: String, responseData: AFDataResponse?, error: NKError) { + await withCheckedContinuation { continuation in + let endpoint = governancePath(entityType: entityType, entityId: entityId) + "/\(labelType.rawValue)/\(labelId)?format=json" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { + continuation.resume(returning: (account, nil, .urlError)) + } + } + + nkSession.sessionData.request(url, method: method, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + let result = self.evaluateResponse(response) + options.queue.async { + continuation.resume(returning: (account, response, result)) + } + } + } + } +} + +private struct GovernanceOCS: Decodable { + let ocs: Inner + + struct Inner: Decodable { + let data: T + } +} diff --git a/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift b/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift new file mode 100644 index 00000000..384b62e1 --- /dev/null +++ b/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import XCTest +@testable import NextcloudKit + +// The per-account `sessionData` config can't be injected with a mock URLProtocol, so these tests +// validate the Codable models and the OCS envelope shape the governance endpoints decode, plus the +// enum raw values that build the request paths. +final class GovernanceUnitTests: XCTestCase { + private struct OCSEnvelope: Decodable { + let ocs: Inner + struct Inner: Decodable { let data: T } + } + + private struct GenericResponse: Decodable { let message: String } + + private func fixture(_ name: String) throws -> Data { + let url = try XCTUnwrap(Bundle.module.url(forResource: name, withExtension: "json")) + return try Data(contentsOf: url) + } + + func test_decodeAvailableLabels_withValidData_shouldParseFieldsAndDropUnknownScopes() throws { + let data = try fixture("GovernanceAvailableLabelsMock") + let labels = try JSONDecoder().decode(OCSEnvelope<[NKGovernanceLabel]>.self, from: data).ocs.data + + XCTAssertEqual(labels.count, 2) + + let first = labels[0] + XCTAssertEqual(first.id, "1") + XCTAssertEqual(first.name, "Public") + XCTAssertEqual(first.priority, 0) + XCTAssertEqual(first.description, "Public label") + XCTAssertEqual(first.color, "#00FF00") + XCTAssertEqual(first.scopes, [.files, .mails]) + + // The unknown "FUTURE_SCOPE" value is dropped rather than failing the whole decode. + XCTAssertEqual(labels[1].scopes, [.files]) + } + + func test_decodeEntityLabels_withSensitivityAndRetention_shouldParseBoth() throws { + let data = try fixture("GovernanceEntityLabelsMock") + let entity = try JSONDecoder().decode(OCSEnvelope.self, from: data).ocs.data + + XCTAssertEqual(entity.sensitivity?.id, "2") + XCTAssertEqual(entity.sensitivity?.name, "Restricted") + XCTAssertEqual(entity.retention.count, 1) + XCTAssertEqual(entity.retention.first?.id, "5") + } + + func test_decodeEntityLabels_withNullSensitivityAndEmptyRetention_shouldDefaultGracefully() throws { + let data = try fixture("GovernanceEntityLabelsEmptyMock") + let entity = try JSONDecoder().decode(OCSEnvelope.self, from: data).ocs.data + + XCTAssertNil(entity.sensitivity) + XCTAssertTrue(entity.retention.isEmpty) + } + + func test_decodeGenericResponse_shouldParseMessage() throws { + let data = try fixture("GovernanceGenericResponseMock") + let response = try JSONDecoder().decode(OCSEnvelope.self, from: data).ocs.data + + XCTAssertEqual(response.message, "Label applied") + } + + func test_labelType_rawValues_shouldMatchApiPathSegments() { + XCTAssertEqual(NKGovernanceLabelType.sensitivity.rawValue, "SENSITIVITY") + XCTAssertEqual(NKGovernanceLabelType.retention.rawValue, "RETENTION") + XCTAssertEqual(NKGovernanceLabelType.hold.rawValue, "HOLD") + } + + func test_labelScope_rawValues_shouldMatchApiValues() { + XCTAssertEqual(NKGovernanceLabelScope(rawValue: "FILES"), .files) + XCTAssertEqual(NKGovernanceLabelScope(rawValue: "MAILS"), .mails) + XCTAssertNil(NKGovernanceLabelScope(rawValue: "FUTURE_SCOPE")) + } +} diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableLabelsMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableLabelsMock.json new file mode 100644 index 00000000..02ce6f46 --- /dev/null +++ b/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableLabelsMock.json @@ -0,0 +1,27 @@ +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": [ + { + "id": "1", + "name": "Public", + "priority": 0, + "description": "Public label", + "color": "#00FF00", + "scopes": ["FILES", "MAILS"] + }, + { + "id": "2", + "name": "Restricted", + "priority": 10, + "description": "Restricted label", + "color": "#FF0000", + "scopes": ["FILES", "FUTURE_SCOPE"] + } + ] + } +} diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsEmptyMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsEmptyMock.json new file mode 100644 index 00000000..32ce32bc --- /dev/null +++ b/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsEmptyMock.json @@ -0,0 +1,13 @@ +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": { + "sensitivity": null, + "retention": [] + } + } +} diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json new file mode 100644 index 00000000..15fb656c --- /dev/null +++ b/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json @@ -0,0 +1,29 @@ +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": { + "sensitivity": { + "id": "2", + "name": "Restricted", + "priority": 10, + "description": "Restricted label", + "color": "#FF0000", + "scopes": ["FILES"] + }, + "retention": [ + { + "id": "5", + "name": "Keep 1 year", + "priority": 1, + "description": "Retain for one year", + "color": "#0000FF", + "scopes": ["FILES"] + } + ] + } + } +} diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceGenericResponseMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceGenericResponseMock.json new file mode 100644 index 00000000..1c2362fa --- /dev/null +++ b/Tests/NextcloudKitUnitTests/Resources/GovernanceGenericResponseMock.json @@ -0,0 +1,12 @@ +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": { + "message": "Label applied" + } + } +} From 57b3e68d1cf929971c3636a2c2585f860111e02e Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 15 Jul 2026 17:46:57 +0200 Subject: [PATCH 2/5] WIP Signed-off-by: Milen Pivchev --- .../NKGovernanceAvailableLabels.swift | 28 ++++++++++ .../Governance/NKGovernanceEntityLabels.swift | 7 ++- .../NextcloudKit+Governance.swift | 54 ++++++++++++++++--- .../GovernanceUnitTests.swift | 12 +++++ .../GovernanceAvailableAllLabelsMock.json | 41 ++++++++++++++ .../Resources/GovernanceEntityLabelsMock.json | 10 ++++ 6 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 Sources/NextcloudKit/Models/Governance/NKGovernanceAvailableLabels.swift create mode 100644 Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceAvailableLabels.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceAvailableLabels.swift new file mode 100644 index 00000000..6643a682 --- /dev/null +++ b/Sources/NextcloudKit/Models/Governance/NKGovernanceAvailableLabels.swift @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +public struct NKGovernanceAvailableLabels: Codable, Sendable, Equatable, Hashable { + public let sensitivity: [NKGovernanceLabel] + public let retention: [NKGovernanceLabel] + public let hold: [NKGovernanceLabel] + + public init(sensitivity: [NKGovernanceLabel], retention: [NKGovernanceLabel], hold: [NKGovernanceLabel]) { + self.sensitivity = sensitivity + self.retention = retention + self.hold = hold + } + + enum CodingKeys: String, CodingKey { + case sensitivity, retention, hold + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + sensitivity = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .sensitivity) ?? [] + retention = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .retention) ?? [] + hold = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .hold) ?? [] + } +} diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift index 249b0e76..7fba2b64 100644 --- a/Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift +++ b/Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift @@ -7,19 +7,22 @@ import Foundation public struct NKGovernanceEntityLabels: Codable, Sendable, Equatable, Hashable { public let sensitivity: NKGovernanceLabel? public let retention: [NKGovernanceLabel] + public let hold: [NKGovernanceLabel] - public init(sensitivity: NKGovernanceLabel?, retention: [NKGovernanceLabel]) { + public init(sensitivity: NKGovernanceLabel?, retention: [NKGovernanceLabel], hold: [NKGovernanceLabel]) { self.sensitivity = sensitivity self.retention = retention + self.hold = hold } enum CodingKeys: String, CodingKey { - case sensitivity, retention + case sensitivity, retention, hold } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) sensitivity = try container.decodeIfPresent(NKGovernanceLabel.self, forKey: .sensitivity) retention = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .retention) ?? [] + hold = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .hold) ?? [] } } diff --git a/Sources/NextcloudKit/NextcloudKit+Governance.swift b/Sources/NextcloudKit/NextcloudKit+Governance.swift index f8576e7b..1de4bc9a 100644 --- a/Sources/NextcloudKit/NextcloudKit+Governance.swift +++ b/Sources/NextcloudKit/NextcloudKit+Governance.swift @@ -13,7 +13,7 @@ public extension NextcloudKit { options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { - await getGovernanceAvailableLabels(entityType: entityType, entityId: entityId, labelKind: "sensitivity", account: account, options: options, taskHandler: taskHandler) + await fetchAvailableGovernanceLabels(entityType: entityType, entityId: entityId, labelKind: "sensitivity", account: account, options: options, taskHandler: taskHandler) } /// Returns the retention labels the user may apply to an entity. @@ -23,7 +23,7 @@ public extension NextcloudKit { options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { - await getGovernanceAvailableLabels(entityType: entityType, entityId: entityId, labelKind: "retention", account: account, options: options, taskHandler: taskHandler) + await fetchAvailableGovernanceLabels(entityType: entityType, entityId: entityId, labelKind: "retention", account: account, options: options, taskHandler: taskHandler) } /// Returns the hold labels the user may apply to an entity. @@ -33,7 +33,49 @@ public extension NextcloudKit { options: NKRequestOptions = NKRequestOptions(), taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { - await getGovernanceAvailableLabels(entityType: entityType, entityId: entityId, labelKind: "hold", account: account, options: options, taskHandler: taskHandler) + await fetchAvailableGovernanceLabels(entityType: entityType, entityId: entityId, labelKind: "hold", account: account, options: options, taskHandler: taskHandler) + } + + /// Returns all labels the user may apply to an entity, grouped by type, in a single request. + func getGovernanceAvailableLabels(entityType: String = "FILES", + entityId: String, + account: String, + options: NKRequestOptions = NKRequestOptions(), + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } + ) async -> (account: String, labels: NKGovernanceAvailableLabels?, responseData: AFDataResponse?, error: NKError) { + await withCheckedContinuation { continuation in + let endpoint = governancePath(entityType: entityType, entityId: entityId) + "/available?format=json" + guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), + let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), + let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { + return options.queue.async { + continuation.resume(returning: (account, nil, nil, .urlError)) + } + } + + nkSession.sessionData.request(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in + task.taskDescription = options.taskDescription + taskHandler(task) + }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in + switch response.result { + case .failure(let error): + let error = NKError(error: error, afResponse: response, responseData: response.data) + options.queue.async { + continuation.resume(returning: (account, nil, response, error)) + } + case .success(let data): + if let result = try? JSONDecoder().decode(GovernanceOCS.self, from: data) { + options.queue.async { + continuation.resume(returning: (account, result.ocs.data, response, .success)) + } + } else { + options.queue.async { + continuation.resume(returning: (account, nil, response, .invalidData)) + } + } + } + } + } } /// Returns all labels applied to an entity, grouped by type and filtered to those visible to the user. @@ -106,9 +148,9 @@ public extension NextcloudKit { "ocs/v2.php/apps/governance/v1/labels/\(entityType)/\(entityId)" } - private func getGovernanceAvailableLabels(entityType: String, - entityId: String, - labelKind: String, + private func fetchAvailableGovernanceLabels(entityType: String, + entityId: String, + labelKind: String, account: String, options: NKRequestOptions, taskHandler: @escaping (_ task: URLSessionTask) -> Void diff --git a/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift b/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift index 384b62e1..ae0466ff 100644 --- a/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift +++ b/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift @@ -47,6 +47,7 @@ final class GovernanceUnitTests: XCTestCase { XCTAssertEqual(entity.sensitivity?.name, "Restricted") XCTAssertEqual(entity.retention.count, 1) XCTAssertEqual(entity.retention.first?.id, "5") + XCTAssertEqual(entity.hold.map(\.id), ["9"]) } func test_decodeEntityLabels_withNullSensitivityAndEmptyRetention_shouldDefaultGracefully() throws { @@ -55,6 +56,7 @@ final class GovernanceUnitTests: XCTestCase { XCTAssertNil(entity.sensitivity) XCTAssertTrue(entity.retention.isEmpty) + XCTAssertTrue(entity.hold.isEmpty) } func test_decodeGenericResponse_shouldParseMessage() throws { @@ -64,6 +66,16 @@ final class GovernanceUnitTests: XCTestCase { XCTAssertEqual(response.message, "Label applied") } + func test_decodeAvailableAllLabels_shouldGroupByType() throws { + let data = try fixture("GovernanceAvailableAllLabelsMock") + let available = try JSONDecoder().decode(OCSEnvelope.self, from: data).ocs.data + + XCTAssertEqual(available.sensitivity.map(\.id), ["1"]) + XCTAssertEqual(available.retention.map(\.id), ["5"]) + XCTAssertEqual(available.hold.map(\.id), ["9"]) + XCTAssertEqual(available.sensitivity.first?.scopes, [.files, .mails]) + } + func test_labelType_rawValues_shouldMatchApiPathSegments() { XCTAssertEqual(NKGovernanceLabelType.sensitivity.rawValue, "SENSITIVITY") XCTAssertEqual(NKGovernanceLabelType.retention.rawValue, "RETENTION") diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json new file mode 100644 index 00000000..858fc139 --- /dev/null +++ b/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json @@ -0,0 +1,41 @@ +{ + "ocs": { + "meta": { + "status": "ok", + "statuscode": 200, + "message": "OK" + }, + "data": { + "sensitivity": [ + { + "id": "1", + "name": "Public", + "priority": 0, + "description": "Public label", + "color": "#00FF00", + "scopes": ["FILES", "MAILS"] + } + ], + "retention": [ + { + "id": "5", + "name": "Keep 1 year", + "priority": 1, + "description": "Retain for one year", + "color": "#0000FF", + "scopes": ["FILES"] + } + ], + "hold": [ + { + "id": "9", + "name": "Legal hold", + "priority": 2, + "description": "Legal hold", + "color": "#FF0000", + "scopes": ["FILES"] + } + ] + } + } +} diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json index 15fb656c..80ef6de3 100644 --- a/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json +++ b/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json @@ -23,6 +23,16 @@ "color": "#0000FF", "scopes": ["FILES"] } + ], + "hold": [ + { + "id": "9", + "name": "Legal hold", + "priority": 2, + "description": "Legal hold", + "color": "#FFAA00", + "scopes": ["FILES"] + } ] } } From 5843dbc694c07811a136fbb9b3d1755a9e362bae Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 22 Jul 2026 15:41:35 +0200 Subject: [PATCH 3/5] WIP Signed-off-by: Milen Pivchev --- .../Governance/NKGovernanceEntityLabels.swift | 28 ----- .../Models/Governance/NKGovernanceLabel.swift | 21 ++-- .../Governance/NKGovernanceLabelScope.swift | 10 -- .../NextcloudKit+Capabilities.swift | 8 ++ .../NextcloudKit+Governance.swift | 114 ------------------ .../GovernanceUnitTests.swift | 101 ++++++---------- .../GovernanceAvailableAllLabelsMock.json | 52 +++++--- .../GovernanceAvailableLabelsMock.json | 27 ----- .../GovernanceEntityLabelsEmptyMock.json | 13 -- .../Resources/GovernanceEntityLabelsMock.json | 39 ------ .../GovernanceGenericResponseMock.json | 12 -- 11 files changed, 90 insertions(+), 335 deletions(-) delete mode 100644 Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift delete mode 100644 Sources/NextcloudKit/Models/Governance/NKGovernanceLabelScope.swift delete mode 100644 Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableLabelsMock.json delete mode 100644 Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsEmptyMock.json delete mode 100644 Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json delete mode 100644 Tests/NextcloudKitUnitTests/Resources/GovernanceGenericResponseMock.json diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift deleted file mode 100644 index 7fba2b64..00000000 --- a/Sources/NextcloudKit/Models/Governance/NKGovernanceEntityLabels.swift +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Milen Pivchev -// SPDX-License-Identifier: GPL-3.0-or-later - -import Foundation - -public struct NKGovernanceEntityLabels: Codable, Sendable, Equatable, Hashable { - public let sensitivity: NKGovernanceLabel? - public let retention: [NKGovernanceLabel] - public let hold: [NKGovernanceLabel] - - public init(sensitivity: NKGovernanceLabel?, retention: [NKGovernanceLabel], hold: [NKGovernanceLabel]) { - self.sensitivity = sensitivity - self.retention = retention - self.hold = hold - } - - enum CodingKeys: String, CodingKey { - case sensitivity, retention, hold - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - sensitivity = try container.decodeIfPresent(NKGovernanceLabel.self, forKey: .sensitivity) - retention = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .retention) ?? [] - hold = try container.decodeIfPresent([NKGovernanceLabel].self, forKey: .hold) ?? [] - } -} diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift index f1cd8b62..81065beb 100644 --- a/Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift +++ b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabel.swift @@ -10,30 +10,31 @@ public struct NKGovernanceLabel: Codable, Sendable, Equatable, Hashable { public let priority: Int public let description: String public let color: String - public let scopes: [NKGovernanceLabelScope] + public let isAssigned: Bool - public init(id: String, name: String, priority: Int, description: String, color: String, scopes: [NKGovernanceLabelScope]) { + public init(id: String, name: String, priority: Int, description: String, color: String, isAssigned: Bool) { self.id = id self.name = name self.priority = priority self.description = description self.color = color - self.scopes = scopes + self.isAssigned = isAssigned } enum CodingKeys: String, CodingKey { - case id, name, priority, description, color, scopes + case id, name, priority, description, color, isAssigned } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(String.self, forKey: .id) name = try container.decode(String.self, forKey: .name) - priority = try container.decode(Int.self, forKey: .priority) - description = try container.decode(String.self, forKey: .description) - color = try container.decode(String.self, forKey: .color) - // Tolerate unknown scope values rather than failing the whole decode. - let rawScopes = try container.decodeIfPresent([String].self, forKey: .scopes) ?? [] - scopes = rawScopes.compactMap(NKGovernanceLabelScope.init(rawValue:)) + priority = try container.decodeIfPresent(Int.self, forKey: .priority) ?? 0 + description = try container.decodeIfPresent(String.self, forKey: .description) ?? "" + + let rawColor = try container.decodeIfPresent(String.self, forKey: .color) ?? "" + color = rawColor.isEmpty || rawColor.hasPrefix("#") ? rawColor : "#\(rawColor)" + + isAssigned = try container.decodeIfPresent(Bool.self, forKey: .isAssigned) ?? false } } diff --git a/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelScope.swift b/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelScope.swift deleted file mode 100644 index d617b62c..00000000 --- a/Sources/NextcloudKit/Models/Governance/NKGovernanceLabelScope.swift +++ /dev/null @@ -1,10 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Milen Pivchev -// SPDX-License-Identifier: GPL-3.0-or-later - -import Foundation - -public enum NKGovernanceLabelScope: String, Codable, Sendable, Equatable, Hashable { - case files = "FILES" - case mails = "MAILS" -} diff --git a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift index a6c6ad70..d6ac4188 100644 --- a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift +++ b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift @@ -133,6 +133,7 @@ public extension NextcloudKit { let recommendations: Recommendations? let termsOfService: TermsOfService? let clientIntegration: NKClientIntegration? + let governance: Governance? enum CodingKeys: String, CodingKey { case downloadLimit = "downloadlimit" @@ -147,8 +148,12 @@ public extension NextcloudKit { case recommendations case termsOfService = "terms_of_service" case clientIntegration = "client_integration" + case governance } + // Presence of this object indicates the governance app is enabled. + struct Governance: Codable {} + struct DownloadLimit: Codable { let enabled: Bool? let defaultLimit: Int? @@ -435,6 +440,8 @@ public extension NextcloudKit { capabilities.activityEnabled = json.activity != nil capabilities.activity = json.activity?.apiv2 ?? [] + capabilities.governanceEnabled = json.governance != nil + capabilities.notification = json.notifications?.ocsendpoints ?? [] capabilities.filesUndelete = json.files?.undelete ?? false @@ -548,6 +555,7 @@ final public class NKCapabilities: Sendable { public var userStatusSupportsBusy: Bool = false public var externalSites: Bool = false public var activityEnabled: Bool = false + public var governanceEnabled: Bool = false public var groupfoldersEnabled: Bool = false // NC27 public var assistantEnabled: Bool = false // NC28 public var isLivePhotoServerAvailable: Bool = false // NC28 diff --git a/Sources/NextcloudKit/NextcloudKit+Governance.swift b/Sources/NextcloudKit/NextcloudKit+Governance.swift index 1de4bc9a..f9b474d1 100644 --- a/Sources/NextcloudKit/NextcloudKit+Governance.swift +++ b/Sources/NextcloudKit/NextcloudKit+Governance.swift @@ -6,36 +6,6 @@ import Foundation import Alamofire public extension NextcloudKit { - /// Returns the sensitivity labels the user may apply to an entity. - func getGovernanceAvailableSensitivityLabels(entityType: String = "FILES", - entityId: String, - account: String, - options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } - ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { - await fetchAvailableGovernanceLabels(entityType: entityType, entityId: entityId, labelKind: "sensitivity", account: account, options: options, taskHandler: taskHandler) - } - - /// Returns the retention labels the user may apply to an entity. - func getGovernanceAvailableRetentionLabels(entityType: String = "FILES", - entityId: String, - account: String, - options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } - ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { - await fetchAvailableGovernanceLabels(entityType: entityType, entityId: entityId, labelKind: "retention", account: account, options: options, taskHandler: taskHandler) - } - - /// Returns the hold labels the user may apply to an entity. - func getGovernanceAvailableHoldLabels(entityType: String = "FILES", - entityId: String, - account: String, - options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } - ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { - await fetchAvailableGovernanceLabels(entityType: entityType, entityId: entityId, labelKind: "hold", account: account, options: options, taskHandler: taskHandler) - } - /// Returns all labels the user may apply to an entity, grouped by type, in a single request. func getGovernanceAvailableLabels(entityType: String = "FILES", entityId: String, @@ -78,48 +48,6 @@ public extension NextcloudKit { } } - /// Returns all labels applied to an entity, grouped by type and filtered to those visible to the user. - func getGovernanceLabels(entityType: String = "FILES", - entityId: String, - account: String, - options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } - ) async -> (account: String, labels: NKGovernanceEntityLabels?, responseData: AFDataResponse?, error: NKError) { - await withCheckedContinuation { continuation in - let endpoint = governancePath(entityType: entityType, entityId: entityId) + "?format=json" - guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), - let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), - let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return options.queue.async { - continuation.resume(returning: (account, nil, nil, .urlError)) - } - } - - nkSession.sessionData.request(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in - task.taskDescription = options.taskDescription - taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): - let error = NKError(error: error, afResponse: response, responseData: response.data) - options.queue.async { - continuation.resume(returning: (account, nil, response, error)) - } - case .success(let data): - if let result = try? JSONDecoder().decode(GovernanceOCS.self, from: data) { - options.queue.async { - continuation.resume(returning: (account, result.ocs.data, response, .success)) - } - } else { - options.queue.async { - continuation.resume(returning: (account, nil, response, .invalidData)) - } - } - } - } - } - } - /// Applies a label to an entity. Only one label per type may be active (except hold, which allows multiple). func setGovernanceLabel(entityType: String = "FILES", entityId: String, @@ -148,48 +76,6 @@ public extension NextcloudKit { "ocs/v2.php/apps/governance/v1/labels/\(entityType)/\(entityId)" } - private func fetchAvailableGovernanceLabels(entityType: String, - entityId: String, - labelKind: String, - account: String, - options: NKRequestOptions, - taskHandler: @escaping (_ task: URLSessionTask) -> Void - ) async -> (account: String, labels: [NKGovernanceLabel]?, responseData: AFDataResponse?, error: NKError) { - await withCheckedContinuation { continuation in - let endpoint = governancePath(entityType: entityType, entityId: entityId) + "/\(labelKind)/available?format=json" - guard let nkSession = nkCommonInstance.nksessions.session(forAccount: account), - let url = nkCommonInstance.createStandardUrl(serverUrl: nkSession.urlBase, endpoint: endpoint), - let headers = nkCommonInstance.getStandardHeaders(account: account, options: options) else { - return options.queue.async { - continuation.resume(returning: (account, nil, nil, .urlError)) - } - } - - nkSession.sessionData.request(url, method: .get, encoding: URLEncoding.default, headers: headers, interceptor: NKInterceptor(nkCommonInstance: nkCommonInstance)).validate(statusCode: 200..<300).onURLSessionTaskCreation { task in - task.taskDescription = options.taskDescription - taskHandler(task) - }.responseData(queue: self.nkCommonInstance.backgroundQueue) { response in - switch response.result { - case .failure(let error): - let error = NKError(error: error, afResponse: response, responseData: response.data) - options.queue.async { - continuation.resume(returning: (account, nil, response, error)) - } - case .success(let data): - if let result = try? JSONDecoder().decode(GovernanceOCS<[NKGovernanceLabel]>.self, from: data) { - options.queue.async { - continuation.resume(returning: (account, result.ocs.data, response, .success)) - } - } else { - options.queue.async { - continuation.resume(returning: (account, nil, response, .invalidData)) - } - } - } - } - } - } - private func applyGovernanceLabel(method: HTTPMethod, entityType: String, entityId: String, diff --git a/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift b/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift index ae0466ff..f9fdb5e2 100644 --- a/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift +++ b/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift @@ -5,86 +5,61 @@ import XCTest @testable import NextcloudKit -// The per-account `sessionData` config can't be injected with a mock URLProtocol, so these tests -// validate the Codable models and the OCS envelope shape the governance endpoints decode, plus the -// enum raw values that build the request paths. final class GovernanceUnitTests: XCTestCase { private struct OCSEnvelope: Decodable { let ocs: Inner - struct Inner: Decodable { let data: T } - } - private struct GenericResponse: Decodable { let message: String } + struct Inner: Decodable { + let data: T + } + } private func fixture(_ name: String) throws -> Data { let url = try XCTUnwrap(Bundle.module.url(forResource: name, withExtension: "json")) return try Data(contentsOf: url) } - func test_decodeAvailableLabels_withValidData_shouldParseFieldsAndDropUnknownScopes() throws { - let data = try fixture("GovernanceAvailableLabelsMock") - let labels = try JSONDecoder().decode(OCSEnvelope<[NKGovernanceLabel]>.self, from: data).ocs.data - - XCTAssertEqual(labels.count, 2) - - let first = labels[0] - XCTAssertEqual(first.id, "1") - XCTAssertEqual(first.name, "Public") - XCTAssertEqual(first.priority, 0) - XCTAssertEqual(first.description, "Public label") - XCTAssertEqual(first.color, "#00FF00") - XCTAssertEqual(first.scopes, [.files, .mails]) - - // The unknown "FUTURE_SCOPE" value is dropped rather than failing the whole decode. - XCTAssertEqual(labels[1].scopes, [.files]) - } - - func test_decodeEntityLabels_withSensitivityAndRetention_shouldParseBoth() throws { - let data = try fixture("GovernanceEntityLabelsMock") - let entity = try JSONDecoder().decode(OCSEnvelope.self, from: data).ocs.data - - XCTAssertEqual(entity.sensitivity?.id, "2") - XCTAssertEqual(entity.sensitivity?.name, "Restricted") - XCTAssertEqual(entity.retention.count, 1) - XCTAssertEqual(entity.retention.first?.id, "5") - XCTAssertEqual(entity.hold.map(\.id), ["9"]) - } + func test_decodeAvailableLabels_shouldGroupByTypeAndMarkAssigned() throws { + let data = try fixture("GovernanceAvailableAllLabelsMock") + let available = try JSONDecoder().decode(OCSEnvelope.self, from: data).ocs.data - func test_decodeEntityLabels_withNullSensitivityAndEmptyRetention_shouldDefaultGracefully() throws { - let data = try fixture("GovernanceEntityLabelsEmptyMock") - let entity = try JSONDecoder().decode(OCSEnvelope.self, from: data).ocs.data + XCTAssertEqual(available.sensitivity.map(\.name), ["Public", "Confidential"]) + XCTAssertEqual(available.retention.count, 2) + XCTAssertTrue(available.hold.isEmpty) - XCTAssertNil(entity.sensitivity) - XCTAssertTrue(entity.retention.isEmpty) - XCTAssertTrue(entity.hold.isEmpty) - } + XCTAssertEqual(available.sensitivity.filter(\.isAssigned).map(\.name), ["Confidential"]) + XCTAssertEqual(available.retention.filter(\.isAssigned).map(\.name), ["Employee Records (HR)"]) - func test_decodeGenericResponse_shouldParseMessage() throws { - let data = try fixture("GovernanceGenericResponseMock") - let response = try JSONDecoder().decode(OCSEnvelope.self, from: data).ocs.data - - XCTAssertEqual(response.message, "Label applied") + XCTAssertEqual(available.sensitivity.first?.color, "#2E7D32") + XCTAssertEqual(available.sensitivity.first?.priority, 0) } - func test_decodeAvailableAllLabels_shouldGroupByType() throws { - let data = try fixture("GovernanceAvailableAllLabelsMock") - let available = try JSONDecoder().decode(OCSEnvelope.self, from: data).ocs.data - - XCTAssertEqual(available.sensitivity.map(\.id), ["1"]) - XCTAssertEqual(available.retention.map(\.id), ["5"]) - XCTAssertEqual(available.hold.map(\.id), ["9"]) - XCTAssertEqual(available.sensitivity.first?.scopes, [.files, .mails]) - } + func test_decodeLabel_withNullOrMissingOptionalFields_shouldStillDecode() throws { + let json = Data(""" + [ + {"id": "1", "name": "Minimal"}, + {"id": "2", "name": "Nulls", "priority": null, "description": null, "color": null, "isAssigned": null} + ] + """.utf8) + let labels = try JSONDecoder().decode([NKGovernanceLabel].self, from: json) - func test_labelType_rawValues_shouldMatchApiPathSegments() { - XCTAssertEqual(NKGovernanceLabelType.sensitivity.rawValue, "SENSITIVITY") - XCTAssertEqual(NKGovernanceLabelType.retention.rawValue, "RETENTION") - XCTAssertEqual(NKGovernanceLabelType.hold.rawValue, "HOLD") + XCTAssertEqual(labels.count, 2) + XCTAssertEqual(labels[0].priority, 0) + XCTAssertEqual(labels[0].description, "") + XCTAssertEqual(labels[0].color, "") + XCTAssertFalse(labels[1].isAssigned) } - func test_labelScope_rawValues_shouldMatchApiValues() { - XCTAssertEqual(NKGovernanceLabelScope(rawValue: "FILES"), .files) - XCTAssertEqual(NKGovernanceLabelScope(rawValue: "MAILS"), .mails) - XCTAssertNil(NKGovernanceLabelScope(rawValue: "FUTURE_SCOPE")) + func test_decodeLabel_withColorMissingHashPrefix_shouldNormalize() throws { + let json = Data(""" + [ + {"id": "1", "name": "A", "color": "FF0000"}, + {"id": "2", "name": "B", "color": "#00FF00"} + ] + """.utf8) + let labels = try JSONDecoder().decode([NKGovernanceLabel].self, from: json) + + XCTAssertEqual(labels[0].color, "#FF0000") + XCTAssertEqual(labels[1].color, "#00FF00") } } diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json index 858fc139..a30a985b 100644 --- a/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json +++ b/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json @@ -8,32 +8,46 @@ "data": { "sensitivity": [ { - "id": "1", + "id": "101463106843779073", "name": "Public", "priority": 0, - "description": "Public label", - "color": "#00FF00", - "scopes": ["FILES", "MAILS"] - } - ], - "retention": [ + "description": "Information intended for public release with no access restrictions.", + "color": "2E7D32", + "canAssign": "yes", + "canRemove": "yes", + "isAssigned": false + }, { - "id": "5", - "name": "Keep 1 year", - "priority": 1, - "description": "Retain for one year", - "color": "#0000FF", - "scopes": ["FILES"] + "id": "101464175724404737", + "name": "Confidential", + "priority": 2, + "description": "Sensitive business information such as contracts, financials, or strategy documents.", + "color": "EF6C00", + "canAssign": "yes", + "canRemove": "yes", + "isAssigned": true } ], - "hold": [ + "retention": [ { - "id": "9", - "name": "Legal hold", + "id": "101921038681186305", + "name": "Employee Records (HR)", "priority": 2, - "description": "Legal hold", - "color": "#FF0000", - "scopes": ["FILES"] + "description": "Personnel files, payroll records, and employment contracts.", + "color": "6A1B9A", + "canAssign": "yes", + "canRemove": "yes", + "isAssigned": true + }, + { + "id": "101921039478104065", + "name": "Customer Personal Data (GDPR)", + "priority": 3, + "description": "Customer personal data collected for support, sales, or marketing purposes.", + "color": "AD1457", + "canAssign": "yes", + "canRemove": "yes", + "isAssigned": false } ] } diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableLabelsMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableLabelsMock.json deleted file mode 100644 index 02ce6f46..00000000 --- a/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableLabelsMock.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "ocs": { - "meta": { - "status": "ok", - "statuscode": 200, - "message": "OK" - }, - "data": [ - { - "id": "1", - "name": "Public", - "priority": 0, - "description": "Public label", - "color": "#00FF00", - "scopes": ["FILES", "MAILS"] - }, - { - "id": "2", - "name": "Restricted", - "priority": 10, - "description": "Restricted label", - "color": "#FF0000", - "scopes": ["FILES", "FUTURE_SCOPE"] - } - ] - } -} diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsEmptyMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsEmptyMock.json deleted file mode 100644 index 32ce32bc..00000000 --- a/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsEmptyMock.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "ocs": { - "meta": { - "status": "ok", - "statuscode": 200, - "message": "OK" - }, - "data": { - "sensitivity": null, - "retention": [] - } - } -} diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json deleted file mode 100644 index 80ef6de3..00000000 --- a/Tests/NextcloudKitUnitTests/Resources/GovernanceEntityLabelsMock.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "ocs": { - "meta": { - "status": "ok", - "statuscode": 200, - "message": "OK" - }, - "data": { - "sensitivity": { - "id": "2", - "name": "Restricted", - "priority": 10, - "description": "Restricted label", - "color": "#FF0000", - "scopes": ["FILES"] - }, - "retention": [ - { - "id": "5", - "name": "Keep 1 year", - "priority": 1, - "description": "Retain for one year", - "color": "#0000FF", - "scopes": ["FILES"] - } - ], - "hold": [ - { - "id": "9", - "name": "Legal hold", - "priority": 2, - "description": "Legal hold", - "color": "#FFAA00", - "scopes": ["FILES"] - } - ] - } - } -} diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceGenericResponseMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceGenericResponseMock.json deleted file mode 100644 index 1c2362fa..00000000 --- a/Tests/NextcloudKitUnitTests/Resources/GovernanceGenericResponseMock.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "ocs": { - "meta": { - "status": "ok", - "statuscode": 200, - "message": "OK" - }, - "data": { - "message": "Label applied" - } - } -} From db13665f1b0b8202b19f0280c90305c697146250 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 22 Jul 2026 15:46:19 +0200 Subject: [PATCH 4/5] WIP Signed-off-by: Milen Pivchev --- .../GovernanceUnitTests.swift | 65 ------------------- .../GovernanceAvailableAllLabelsMock.json | 55 ---------------- 2 files changed, 120 deletions(-) delete mode 100644 Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift delete mode 100644 Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json diff --git a/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift b/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift deleted file mode 100644 index f9fdb5e2..00000000 --- a/Tests/NextcloudKitUnitTests/GovernanceUnitTests.swift +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Milen Pivchev -// SPDX-License-Identifier: GPL-3.0-or-later - -import XCTest -@testable import NextcloudKit - -final class GovernanceUnitTests: XCTestCase { - private struct OCSEnvelope: Decodable { - let ocs: Inner - - struct Inner: Decodable { - let data: T - } - } - - private func fixture(_ name: String) throws -> Data { - let url = try XCTUnwrap(Bundle.module.url(forResource: name, withExtension: "json")) - return try Data(contentsOf: url) - } - - func test_decodeAvailableLabels_shouldGroupByTypeAndMarkAssigned() throws { - let data = try fixture("GovernanceAvailableAllLabelsMock") - let available = try JSONDecoder().decode(OCSEnvelope.self, from: data).ocs.data - - XCTAssertEqual(available.sensitivity.map(\.name), ["Public", "Confidential"]) - XCTAssertEqual(available.retention.count, 2) - XCTAssertTrue(available.hold.isEmpty) - - XCTAssertEqual(available.sensitivity.filter(\.isAssigned).map(\.name), ["Confidential"]) - XCTAssertEqual(available.retention.filter(\.isAssigned).map(\.name), ["Employee Records (HR)"]) - - XCTAssertEqual(available.sensitivity.first?.color, "#2E7D32") - XCTAssertEqual(available.sensitivity.first?.priority, 0) - } - - func test_decodeLabel_withNullOrMissingOptionalFields_shouldStillDecode() throws { - let json = Data(""" - [ - {"id": "1", "name": "Minimal"}, - {"id": "2", "name": "Nulls", "priority": null, "description": null, "color": null, "isAssigned": null} - ] - """.utf8) - let labels = try JSONDecoder().decode([NKGovernanceLabel].self, from: json) - - XCTAssertEqual(labels.count, 2) - XCTAssertEqual(labels[0].priority, 0) - XCTAssertEqual(labels[0].description, "") - XCTAssertEqual(labels[0].color, "") - XCTAssertFalse(labels[1].isAssigned) - } - - func test_decodeLabel_withColorMissingHashPrefix_shouldNormalize() throws { - let json = Data(""" - [ - {"id": "1", "name": "A", "color": "FF0000"}, - {"id": "2", "name": "B", "color": "#00FF00"} - ] - """.utf8) - let labels = try JSONDecoder().decode([NKGovernanceLabel].self, from: json) - - XCTAssertEqual(labels[0].color, "#FF0000") - XCTAssertEqual(labels[1].color, "#00FF00") - } -} diff --git a/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json b/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json deleted file mode 100644 index a30a985b..00000000 --- a/Tests/NextcloudKitUnitTests/Resources/GovernanceAvailableAllLabelsMock.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "ocs": { - "meta": { - "status": "ok", - "statuscode": 200, - "message": "OK" - }, - "data": { - "sensitivity": [ - { - "id": "101463106843779073", - "name": "Public", - "priority": 0, - "description": "Information intended for public release with no access restrictions.", - "color": "2E7D32", - "canAssign": "yes", - "canRemove": "yes", - "isAssigned": false - }, - { - "id": "101464175724404737", - "name": "Confidential", - "priority": 2, - "description": "Sensitive business information such as contracts, financials, or strategy documents.", - "color": "EF6C00", - "canAssign": "yes", - "canRemove": "yes", - "isAssigned": true - } - ], - "retention": [ - { - "id": "101921038681186305", - "name": "Employee Records (HR)", - "priority": 2, - "description": "Personnel files, payroll records, and employment contracts.", - "color": "6A1B9A", - "canAssign": "yes", - "canRemove": "yes", - "isAssigned": true - }, - { - "id": "101921039478104065", - "name": "Customer Personal Data (GDPR)", - "priority": 3, - "description": "Customer personal data collected for support, sales, or marketing purposes.", - "color": "AD1457", - "canAssign": "yes", - "canRemove": "yes", - "isAssigned": false - } - ] - } - } -} From 73e6361438156b06a5b0f608d03675969f575f58 Mon Sep 17 00:00:00 2001 From: Milen Pivchev Date: Wed, 22 Jul 2026 16:02:12 +0200 Subject: [PATCH 5/5] WIP Signed-off-by: Milen Pivchev --- .../NextcloudKit+Capabilities.swift | 27 ------------------- 1 file changed, 27 deletions(-) diff --git a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift index d6ac4188..de4e898d 100644 --- a/Sources/NextcloudKit/NextcloudKit+Capabilities.swift +++ b/Sources/NextcloudKit/NextcloudKit+Capabilities.swift @@ -151,7 +151,6 @@ public extension NextcloudKit { case governance } - // Presence of this object indicates the governance app is enabled. struct Governance: Codable {} struct DownloadLimit: Codable { @@ -355,32 +354,6 @@ public extension NextcloudKit { struct Recommendations: Codable { let enabled: Bool? } - -// struct DeclarativeUI: Codable { -// let contextMenu: [[ContextMenuItem]] -// -// enum CodingKeys: String, CodingKey { -// case contextMenu = "context-menu" -// } -// } - -// -// struct DeclarativeUI: Codable { -// let contextMenus: [ContextMenu] -// -// enum CodingKeys: String, CodingKey { -// case contextMenus = "context-menu" -// } -// -// struct ContextMenu: Codable { -// let items -// } -// -// struct ContextMenuItem: Codable { -// let title: String -// let endpoint: String -// } -// } } } }