diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index 69d281c29..f4c50a76e 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -121,7 +121,7 @@ steps: echo "--- 🧹 Linting" # This is a temporary step until we implement a more graceful way to handle missing credentials - printf "site_url\nadmin_username\nadmin_password\nsubscriber_username\nsubscriber_password\n" > test_credentials + printf "site_url\nadmin_username\nadmin_password\nadmin_password_uuid\nsubscriber_username\nsubscriber_password\nsubscriber_password_uuid\n" > test_credentials cd ./native/kotlin ./gradlew detektMain detektTest diff --git a/Cargo.lock b/Cargo.lock index 71c3401da..495ecb99c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1691,6 +1691,15 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1" +[[package]] +name = "scc" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ad2bbb0ae5100a07b7a6f2ed7ab5fd0045551a4c507989b7a620046ea3efdc" +dependencies = [ + "sdd", +] + [[package]] name = "schannel" version = "0.1.23" @@ -1726,6 +1735,12 @@ dependencies = [ "syn 2.0.60", ] +[[package]] +name = "sdd" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84345e4c9bd703274a082fb80caaa99b7612be48dfaa1dd9266577ec412309d" + [[package]] name = "security-framework" version = "2.11.0" @@ -1810,6 +1825,31 @@ dependencies = [ "serde", ] +[[package]] +name = "serial_test" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4b487fe2acf240a021cf57c6b2b4903b1e78ca0ecd862a71b71d2a51fed77d" +dependencies = [ + "futures", + "log", + "once_cell", + "parking_lot", + "scc", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.60", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3016,6 +3056,7 @@ dependencies = [ "rstest_reuse", "serde", "serde_json", + "serial_test", "sqlx", "thiserror", "tokio", diff --git a/Makefile b/Makefile index ef98dc025..4ec16d210 100644 --- a/Makefile +++ b/Makefile @@ -225,3 +225,6 @@ setup-rust-android-targets: i686-linux-android \ armv7-linux-androideabi \ aarch64-linux-android + +run-wp-cli-command: + docker exec -it wordpress /bin/bash -c "wp --allow-root $(ARGS)" diff --git a/native/kotlin/api/android/build.gradle.kts b/native/kotlin/api/android/build.gradle.kts index 2f8186f83..2f7d41392 100644 --- a/native/kotlin/api/android/build.gradle.kts +++ b/native/kotlin/api/android/build.gradle.kts @@ -33,6 +33,7 @@ android { buildConfigField("String", "TEST_SITE_URL", "\"${it.siteUrl}\"") buildConfigField("String", "TEST_ADMIN_USERNAME", "\"${it.adminUsername}\"") buildConfigField("String", "TEST_ADMIN_PASSWORD", "\"${it.adminPassword}\"") + buildConfigField("String", "TEST_ADMIN_PASSWORD_UUID", "\"${it.adminPasswordUuid}\"") buildConfigField( "String", "TEST_SUBSCRIBER_USERNAME", @@ -43,6 +44,7 @@ android { "TEST_SUBSCRIBER_PASSWORD", "\"${it.subscriberPassword}\"" ) + buildConfigField("String", "TEST_SUBSCRIBER_PASSWORD_UUID", "\"${it.subscriberPasswordUuid}\"") } } } @@ -163,8 +165,10 @@ fun readTestCredentials(): TestCredentials? { siteUrl = siteUrl, adminUsername = lines[1], adminPassword = lines[2], - subscriberUsername = lines[3], - subscriberPassword = lines[4] + adminPasswordUuid = lines[3], + subscriberUsername = lines[4], + subscriberPassword = lines[5], + subscriberPasswordUuid = lines[6] ) } @@ -172,6 +176,8 @@ data class TestCredentials( val siteUrl: String, val adminUsername: String, val adminPassword: String, + val adminPasswordUuid: String, val subscriberUsername: String, val subscriberPassword: String, + val subscriberPasswordUuid: String, ) diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/ApplicationPasswordsEndpointTest.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/ApplicationPasswordsEndpointTest.kt new file mode 100644 index 000000000..e907e12b6 --- /dev/null +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/ApplicationPasswordsEndpointTest.kt @@ -0,0 +1,53 @@ +package rs.wordpress.api.kotlin + +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import uniffi.wp_api.ApplicationPasswordUuid +import uniffi.wp_api.wpAuthenticationFromUsernameAndPassword +import kotlin.test.assertEquals + +class ApplicationPasswordsEndpointTest { + private val testCredentials = TestCredentials.INSTANCE + private val siteUrl = testCredentials.siteUrl + private val authentication = wpAuthenticationFromUsernameAndPassword( + username = testCredentials.adminUsername, password = testCredentials.adminPassword + ) + private val client = WpApiClient(siteUrl, authentication) + + @Test + fun testApplicationPasswordListRequest() = runTest { + val result = client.request { requestBuilder -> + requestBuilder.applicationPasswords().listWithEditContext(FIRST_USER_ID) + } + assert(result is WpRequestSuccess) + val applicationPasswordList = (result as WpRequestSuccess).data + assertEquals( + ApplicationPasswordUuid(testCredentials.adminPasswordUuid), + applicationPasswordList.first().uuid + ) + } + + @Test + fun testApplicationPasswordRetrieveRequest() = runTest { + val uuid = ApplicationPasswordUuid(testCredentials.adminPasswordUuid) + val result = client.request { requestBuilder -> + requestBuilder.applicationPasswords().retrieveWithEditContext(FIRST_USER_ID, uuid) + } + assert(result is WpRequestSuccess) + val applicationPasswordList = (result as WpRequestSuccess).data + assertEquals(uuid, applicationPasswordList.uuid) + } + + @Test + fun testApplicationPasswordRetrieveCurrentRequest() = runTest { + val result = client.request { requestBuilder -> + requestBuilder.applicationPasswords().retrieveCurrentWithEditContext(FIRST_USER_ID) + } + assert(result is WpRequestSuccess) + val applicationPasswordList = (result as WpRequestSuccess).data + assertEquals( + ApplicationPasswordUuid(testCredentials.adminPasswordUuid), + applicationPasswordList.uuid + ) + } +} \ No newline at end of file diff --git a/native/kotlin/api/kotlin/src/integrationTest/kotlin/TestCredentials.kt b/native/kotlin/api/kotlin/src/integrationTest/kotlin/TestCredentials.kt index 9d8dc2e3e..ae7fc84c0 100644 --- a/native/kotlin/api/kotlin/src/integrationTest/kotlin/TestCredentials.kt +++ b/native/kotlin/api/kotlin/src/integrationTest/kotlin/TestCredentials.kt @@ -6,8 +6,10 @@ data class TestCredentials( val siteUrl: String, val adminUsername: String, val adminPassword: String, + val adminPasswordUuid: String, val subscriberUsername: String, - val subscriberPassword: String + val subscriberPassword: String, + val subscriberPasswordUuid: String ) { companion object { val INSTANCE: TestCredentials by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { @@ -22,8 +24,10 @@ data class TestCredentials( siteUrl = lineList[0], adminUsername = lineList[1], adminPassword = lineList[2], - subscriberUsername = lineList[3], - subscriberPassword = lineList[4], + adminPasswordUuid = lineList[3], + subscriberUsername = lineList[4], + subscriberPassword = lineList[5], + subscriberPasswordUuid = lineList[6], ) } } diff --git a/native/swift/Example/Example.xcodeproj/project.pbxproj b/native/swift/Example/Example.xcodeproj/project.pbxproj index 70544f3e8..cd5c0f6ba 100644 --- a/native/swift/Example/Example.xcodeproj/project.pbxproj +++ b/native/swift/Example/Example.xcodeproj/project.pbxproj @@ -7,25 +7,31 @@ objects = { /* Begin PBXBuildFile section */ + 242D648E2C3602C1007CA96C /* ListViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D648D2C3602C1007CA96C /* ListViewData.swift */; }; + 242D64922C360687007CA96C /* RootListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64912C360687007CA96C /* RootListView.swift */; }; + 242D64942C3608C6007CA96C /* ListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64932C3608C6007CA96C /* ListView.swift */; }; + 242D64962C360EB3007CA96C /* WordPressAPI+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 242D64952C360EB3007CA96C /* WordPressAPI+Extensions.swift */; }; 2479BF812B621CB60014A01D /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2479BF802B621CB60014A01D /* ExampleApp.swift */; }; - 2479BF832B621CB60014A01D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2479BF822B621CB60014A01D /* ContentView.swift */; }; 2479BF852B621CB70014A01D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2479BF842B621CB70014A01D /* Assets.xcassets */; }; 2479BF892B621CB70014A01D /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2479BF882B621CB70014A01D /* Preview Assets.xcassets */; }; 2479BF912B621CCA0014A01D /* WordPressAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 2479BF902B621CCA0014A01D /* WordPressAPI */; }; - 2479BF932B621E9B0014A01D /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2479BF922B621E9B0014A01D /* UserListViewModel.swift */; }; + 2479BF932B621E9B0014A01D /* ListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2479BF922B621E9B0014A01D /* ListViewModel.swift */; }; 24A3C32F2BA8F96F00162AD1 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A3C32E2BA8F96F00162AD1 /* LoginView.swift */; }; 24A3C3362BAA874C00162AD1 /* LoginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24A3C3352BAA874C00162AD1 /* LoginManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 242D648D2C3602C1007CA96C /* ListViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewData.swift; sourceTree = ""; }; + 242D64912C360687007CA96C /* RootListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootListView.swift; sourceTree = ""; }; + 242D64932C3608C6007CA96C /* ListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListView.swift; sourceTree = ""; }; + 242D64952C360EB3007CA96C /* WordPressAPI+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WordPressAPI+Extensions.swift"; sourceTree = ""; }; 2479BF7D2B621CB60014A01D /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 2479BF802B621CB60014A01D /* ExampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleApp.swift; sourceTree = ""; }; - 2479BF822B621CB60014A01D /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 2479BF842B621CB70014A01D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 2479BF882B621CB70014A01D /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - 2479BF922B621E9B0014A01D /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = ""; }; + 2479BF922B621E9B0014A01D /* ListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViewModel.swift; sourceTree = ""; }; 24A3C32E2BA8F96F00162AD1 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; - 24A3C3342BAA45B800162AD1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 24A3C3342BAA45B800162AD1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 24A3C3352BAA874C00162AD1 /* LoginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginManager.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -41,6 +47,26 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 242D64972C363960007CA96C /* UI */ = { + isa = PBXGroup; + children = ( + 2479BF872B621CB70014A01D /* Preview Content */, + 242D64932C3608C6007CA96C /* ListView.swift */, + 242D64912C360687007CA96C /* RootListView.swift */, + 24A3C32E2BA8F96F00162AD1 /* LoginView.swift */, + ); + path = UI; + sourceTree = ""; + }; + 242D64982C363996007CA96C /* Resources */ = { + isa = PBXGroup; + children = ( + 24A3C3342BAA45B800162AD1 /* Info.plist */, + 2479BF842B621CB70014A01D /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; 2479BF742B621CB60014A01D = { isa = PBXGroup; children = ( @@ -60,14 +86,13 @@ 2479BF7F2B621CB60014A01D /* Example */ = { isa = PBXGroup; children = ( - 24A3C3342BAA45B800162AD1 /* Info.plist */, + 242D64972C363960007CA96C /* UI */, + 242D64982C363996007CA96C /* Resources */, 2479BF802B621CB60014A01D /* ExampleApp.swift */, - 24A3C32E2BA8F96F00162AD1 /* LoginView.swift */, - 2479BF822B621CB60014A01D /* ContentView.swift */, - 2479BF842B621CB70014A01D /* Assets.xcassets */, - 2479BF872B621CB70014A01D /* Preview Content */, - 2479BF922B621E9B0014A01D /* UserListViewModel.swift */, + 2479BF922B621E9B0014A01D /* ListViewModel.swift */, 24A3C3352BAA874C00162AD1 /* LoginManager.swift */, + 242D648D2C3602C1007CA96C /* ListViewData.swift */, + 242D64952C360EB3007CA96C /* WordPressAPI+Extensions.swift */, ); path = Example; sourceTree = ""; @@ -156,11 +181,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2479BF832B621CB60014A01D /* ContentView.swift in Sources */, + 242D64922C360687007CA96C /* RootListView.swift in Sources */, 2479BF812B621CB60014A01D /* ExampleApp.swift in Sources */, 24A3C32F2BA8F96F00162AD1 /* LoginView.swift in Sources */, - 2479BF932B621E9B0014A01D /* UserListViewModel.swift in Sources */, + 2479BF932B621E9B0014A01D /* ListViewModel.swift in Sources */, 24A3C3362BAA874C00162AD1 /* LoginManager.swift in Sources */, + 242D64962C360EB3007CA96C /* WordPressAPI+Extensions.swift in Sources */, + 242D648E2C3602C1007CA96C /* ListViewData.swift in Sources */, + 242D64942C3608C6007CA96C /* ListView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -288,10 +316,10 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = "\"Example/UI/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Example/Info.plist; + INFOPLIST_FILE = Example/Resources/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -326,10 +354,10 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Manual; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"Example/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = "\"Example/UI/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Example/Info.plist; + INFOPLIST_FILE = Example/Resources/Info.plist; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; @@ -390,7 +418,7 @@ /* Begin XCSwiftPackageProductDependency section */ 2479BF902B621CCA0014A01D /* WordPressAPI */ = { isa = XCSwiftPackageProductDependency; - productName = "WordPressAPI"; + productName = WordPressAPI; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/native/swift/Example/Example/ContentView.swift b/native/swift/Example/Example/ContentView.swift deleted file mode 100644 index d77b76887..000000000 --- a/native/swift/Example/Example/ContentView.swift +++ /dev/null @@ -1,71 +0,0 @@ -import SwiftUI -import WordPressAPI - -struct ContentView: View { - - @State - private var viewModel: UserListViewModel - - @EnvironmentObject - var loginManager: LoginManager - - init(viewModel: UserListViewModel) { - self.viewModel = viewModel - } - - var body: some View { - Group { - if viewModel.users.isEmpty { - VStack { - ProgressView().progressViewStyle(.circular) - Text("Fetching users") - } - .padding() - } else { - List(viewModel.users) { - Text($0.name) - } - } - } - .onAppear(perform: viewModel.startFetching) -// .onDisappear(perform: viewModel.stopFetching) - .alert( - isPresented: $viewModel.shouldPresentAlert, - error: viewModel.error, - actions: { error in // 2 - if let suggestion = error.recoverySuggestion { - Button(suggestion, action: { - // Recover from an error - }) - } - }, message: { error in // 3 - if let failureReason = error.failureReason { - Text(failureReason) - } else { - Text("Something went wrong") - } - }).toolbar(content: { - #if os(macOS) - ToolbarItem { - Button("Log Out") { - Task { - await loginManager.logout() - } - } - } - #else - ToolbarItem(placement: .bottomBar) { - Button("Log Out") { - Task { - await loginManager.logout() - } - } - } - #endif - }) - } -} -// -// #Preview { -// ContentView() -// } diff --git a/native/swift/Example/Example/ExampleApp.swift b/native/swift/Example/Example/ExampleApp.swift index ad73d32be..ea5b318f7 100644 --- a/native/swift/Example/Example/ExampleApp.swift +++ b/native/swift/Example/Example/ExampleApp.swift @@ -9,7 +9,34 @@ struct ExampleApp: App { var body: some Scene { WindowGroup { if loginManager.isLoggedIn { - ContentView(viewModel: UserListViewModel(loginManager: self.loginManager)) + NavigationView { + // The first column is the sidebar. + RootListView() + + // Initial content of the second column. + EmptyView() + + // Initial content for the third column. + Text("Select a category of settings in the sidebar.") + }.toolbar(content: { + #if os(macOS) + ToolbarItem { + Button("Log Out") { + Task { + await loginManager.logout() + } + } + } + #else + ToolbarItem(placement: .bottomBar) { + Button("Log Out") { + Task { + await loginManager.logout() + } + } + } + #endif + }) } else { LoginView() } diff --git a/native/swift/Example/Example/ListViewData.swift b/native/swift/Example/Example/ListViewData.swift new file mode 100644 index 000000000..fcfd24523 --- /dev/null +++ b/native/swift/Example/Example/ListViewData.swift @@ -0,0 +1,72 @@ +import Foundation +import WordPressAPI + +struct ListViewData: Identifiable { + let id: String + let title: String + let subtitle: String + let fields: [String: String] +} + +protocol ListViewDataConvertable: Identifiable { + var asListViewData: ListViewData { get } +} + +extension UserWithEditContext: ListViewDataConvertable { + var asListViewData: ListViewData { + ListViewData(id: "user-\(self.id)", title: self.name, subtitle: self.email, fields: [ + "First Name": self.firstName, + "Last Name": self.lastName, + "Email": self.email + ]) + } +} + +extension UserWithViewContext: ListViewDataConvertable { + var asListViewData: ListViewData { + ListViewData(id: "user-\(self.id)", title: self.name, subtitle: self.slug, fields: [ + "Name": self.name + ]) + } +} + +extension UserWithEmbedContext: ListViewDataConvertable { + var asListViewData: ListViewData { + ListViewData(id: "user-\(self.id)", title: self.name, subtitle: self.slug, fields: [ + "Name": self.name + ]) + } +} + +extension PluginWithEditContext: ListViewDataConvertable { + public var id: String { + self.plugin.slug + } + + var asListViewData: ListViewData { + ListViewData(id: self.plugin.slug, title: self.name, subtitle: self.version, fields: [ + "Author": self.author, + "Author URI": self.authorUri + ]) + } +} + +extension ApplicationPasswordWithEditContext: ListViewDataConvertable { + public var id: String { + self.uuid.uuid + } + + var creationDateString: String { + guard let date = Date.fromWordPressDate(self.created) else { + return self.created + } + + return RelativeDateTimeFormatter().string(for: date) ?? self.created + } + + var asListViewData: ListViewData { + ListViewData(id: self.uuid.uuid, title: self.name, subtitle: creationDateString, fields: [ + "Created": creationDateString + ]) + } +} diff --git a/native/swift/Example/Example/ListViewModel.swift b/native/swift/Example/Example/ListViewModel.swift new file mode 100644 index 000000000..cb8da680c --- /dev/null +++ b/native/swift/Example/Example/ListViewModel.swift @@ -0,0 +1,58 @@ +import Foundation +import SwiftUI +import WordPressAPI + +@Observable class ListViewModel { + + var listItems: [ListViewData] = [] + var fetchDataTask: Task<[ListViewData], Error> + var isLoading: Bool = false + + var error: MyError? + var shouldPresentAlert = false + + let loginManager: LoginManager + + init(loginManager: LoginManager, fetchDataTask: Task<[ListViewData], Error>) { + self.loginManager = loginManager + self.fetchDataTask = fetchDataTask + } + + func startFetching() { + self.error = nil + self.shouldPresentAlert = false + + Task { @MainActor in + self.isLoading = true + + do { + self.listItems = try await self.fetchDataTask.value + } catch { + self.error = MyError(underlyingError: error) + self.shouldPresentAlert = true + } + + self.isLoading = false + } + } + + func stopFetching() { + self.fetchDataTask.cancel() + } +} + +struct MyError: LocalizedError { + var underlyingError: Error + + var localizedDescription: String { + underlyingError.localizedDescription + } + + var errorDescription: String? { + "Unable to fetch data" + } + + var failureReason: String? { + underlyingError.localizedDescription + } +} diff --git a/native/swift/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json b/native/swift/Example/Example/Resources/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from native/swift/Example/Example/Assets.xcassets/AccentColor.colorset/Contents.json rename to native/swift/Example/Example/Resources/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/native/swift/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json b/native/swift/Example/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from native/swift/Example/Example/Assets.xcassets/AppIcon.appiconset/Contents.json rename to native/swift/Example/Example/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/native/swift/Example/Example/Assets.xcassets/Contents.json b/native/swift/Example/Example/Resources/Assets.xcassets/Contents.json similarity index 100% rename from native/swift/Example/Example/Assets.xcassets/Contents.json rename to native/swift/Example/Example/Resources/Assets.xcassets/Contents.json diff --git a/native/swift/Example/Example/Info.plist b/native/swift/Example/Example/Resources/Info.plist similarity index 100% rename from native/swift/Example/Example/Info.plist rename to native/swift/Example/Example/Resources/Info.plist diff --git a/native/swift/Example/Example/UI/ListView.swift b/native/swift/Example/Example/UI/ListView.swift new file mode 100644 index 000000000..17c8468bf --- /dev/null +++ b/native/swift/Example/Example/UI/ListView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct ListView: View { + + @State + var viewModel: ListViewModel + + var body: some View { + List(viewModel.listItems) { item in + VStack(alignment: .leading) { + Text(item.title).font(.headline) + Text(item.subtitle).font(.footnote) + } + } + .alert( + isPresented: $viewModel.shouldPresentAlert, + error: viewModel.error, + actions: { error in // 2 + if let suggestion = error.recoverySuggestion { + Button(suggestion, action: { + // Recover from an error + }) + } + }, message: { error in // 3 + if let failureReason = error.failureReason { + Text(failureReason) + } else { + Text("Something went wrong") + } + } + ) + .onAppear(perform: viewModel.startFetching) + .onDisappear(perform: viewModel.stopFetching) + } +} + +#Preview { + + let viewModel = ListViewModel(loginManager: LoginManager(), fetchDataTask: Task(operation: { + [ + ListViewData(id: "1234", title: "Item 1", subtitle: "Subtitle", fields: [:]) + ] + })) + + return ListView(viewModel: viewModel) +} diff --git a/native/swift/Example/Example/LoginView.swift b/native/swift/Example/Example/UI/LoginView.swift similarity index 98% rename from native/swift/Example/Example/LoginView.swift rename to native/swift/Example/Example/UI/LoginView.swift index d5a1860e4..ab6a1e242 100644 --- a/native/swift/Example/Example/LoginView.swift +++ b/native/swift/Example/Example/UI/LoginView.swift @@ -71,8 +71,9 @@ struct LoginView: View { else { abort() // TODO: Better error handling } - + debugPrint(authURL) let loginDetails = try await displayLoginView(withAuthenticationUrl: authURL) + debugPrint(loginDetails) try await loginManager.setLoginCredentials(to: loginDetails) } catch let err { handleLoginError(err) diff --git a/native/swift/Example/Example/Preview Content/Preview Assets.xcassets/Contents.json b/native/swift/Example/Example/UI/Preview Content/Preview Assets.xcassets/Contents.json similarity index 100% rename from native/swift/Example/Example/Preview Content/Preview Assets.xcassets/Contents.json rename to native/swift/Example/Example/UI/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/native/swift/Example/Example/UI/RootListView.swift b/native/swift/Example/Example/UI/RootListView.swift new file mode 100644 index 000000000..01cd01a59 --- /dev/null +++ b/native/swift/Example/Example/UI/RootListView.swift @@ -0,0 +1,59 @@ +import SwiftUI +import WordPressAPI + +struct RootListView: View { + + let items = [ + RootListData(name: "Application Passwords", callback: Task(operation: { + try await WordPressAPI.globalInstance.applicationPasswords.listWithEditContext(userId: 1) + .map { $0.asListViewData } + })), + RootListData(name: "Users", callback: Task(operation: { + try await WordPressAPI.globalInstance.users.listWithEditContext(params: .init()) + .map { $0.asListViewData } + })), + RootListData(name: "Plugins", callback: Task(operation: { + try await WordPressAPI.globalInstance.plugins.listWithEditContext(params: .init()) + .map { $0.asListViewData } + })) + ] + + var body: some View { + List(self.items) { data in + RootListViewItem(item: data) + } + } +} + +struct RootListViewItem: View { + let item: RootListData + + var body: some View { + VStack(alignment: .leading, spacing: 4.0) { + NavigationLink { + ListView( + viewModel: ListViewModel( + loginManager: LoginManager(), + fetchDataTask: self.item.callback + ) + ) + } label: { + Text(item.name) + } + } + } +} + +struct RootListData: Identifiable { + + let name: String + let callback: Task<[ListViewData], Error> + + var id: String { + self.name + } +} + +#Preview { + RootListView() +} diff --git a/native/swift/Example/Example/UserListViewModel.swift b/native/swift/Example/Example/UserListViewModel.swift deleted file mode 100644 index 7fac6d018..000000000 --- a/native/swift/Example/Example/UserListViewModel.swift +++ /dev/null @@ -1,69 +0,0 @@ -import Foundation -import SwiftUI -import WordPressAPI - -#if hasFeature(RetroactiveAttribute) -extension UserWithViewContext: @retroactive Identifiable {} -#else -extension UserWithViewContext: Identifiable {} -#endif - -@Observable class UserListViewModel { - - var users: [UserWithViewContext] - var fetchUsersTask: Task? - var error: MyError? - var shouldPresentAlert = false - - let loginManager: LoginManager - - // swiftlint:disable force_try - var api: WordPressAPI { - try! WordPressAPI( - urlSession: .shared, - baseUrl: URL(string: loginManager.getDefaultSiteUrl()!)!, - authenticationStategy: try! loginManager.getLoginCredentials()! - ) - } - // swiftlint:enable force_try - - init(loginManager: LoginManager, users: [UserWithViewContext] = []) { - self.loginManager = loginManager - self.users = users - } - - func startFetching() { - self.error = nil - self.shouldPresentAlert = false - - self.fetchUsersTask = Task { @MainActor in - do { - users = try await api.users.listWithViewContext(params: .init()) - } catch let error { - shouldPresentAlert = true - self.error = MyError(underlyingError: error) - debugPrint(error.localizedDescription) - } - } - } - - func stopFetching() { - self.fetchUsersTask?.cancel() - } -} - -struct MyError: LocalizedError { - var underlyingError: Error - - var localizedDescription: String { - underlyingError.localizedDescription - } - - var errorDescription: String? { - "Unable to fetch users" - } - - var failureReason: String? { - underlyingError.localizedDescription - } -} diff --git a/native/swift/Example/Example/WordPressAPI+Extensions.swift b/native/swift/Example/Example/WordPressAPI+Extensions.swift new file mode 100644 index 000000000..c506c5c80 --- /dev/null +++ b/native/swift/Example/Example/WordPressAPI+Extensions.swift @@ -0,0 +1,17 @@ +import Foundation +import WordPressAPI + +extension WordPressAPI { + static var globalInstance: WordPressAPI { + get throws { + let loginManager = LoginManager() + + return try WordPressAPI( + urlSession: .shared, + baseUrl: URL(string: loginManager.getDefaultSiteUrl()!)!, + authenticationStategy: loginManager.getLoginCredentials()! + ) + } + } + +} diff --git a/native/swift/Sources/wordpress-api/Exports.swift b/native/swift/Sources/wordpress-api/Exports.swift index 55031ee88..6052be892 100644 --- a/native/swift/Sources/wordpress-api/Exports.swift +++ b/native/swift/Sources/wordpress-api/Exports.swift @@ -41,4 +41,11 @@ public typealias PluginCreateParams = WordPressAPIInternal.PluginCreateParams public typealias PluginDeleteResponse = WordPressAPIInternal.PluginDeleteResponse public typealias PluginsRequestExecutor = WordPressAPIInternal.PluginsRequestExecutor +// MARK: – Application Passwords + +public typealias SparseApplicationPassword = WordPressAPIInternal.SparseApplicationPassword +public typealias ApplicationPasswordWithEditContext = WordPressAPIInternal.ApplicationPasswordWithEditContext +public typealias ApplicationPasswordWithViewContext = WordPressAPIInternal.ApplicationPasswordWithViewContext +public typealias ApplicationPasswordWithEmbedContext = WordPressAPIInternal.ApplicationPasswordWithEmbedContext + #endif diff --git a/native/swift/Sources/wordpress-api/Foundation+Extensions.swift b/native/swift/Sources/wordpress-api/Foundation+Extensions.swift new file mode 100644 index 000000000..760feba26 --- /dev/null +++ b/native/swift/Sources/wordpress-api/Foundation+Extensions.swift @@ -0,0 +1,19 @@ +import Foundation + +public extension Date { + + private static let wordpressDateFormatter: DateFormatter = { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(abbreviation: "GMT") + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + + return dateFormatter + }() + + /// Parses a date string provided by WordPress APIs (which are assumed to be in GMT) + /// + static func fromWordPressDate(_ string: String) -> Date? { + wordpressDateFormatter.date(from: string) + } +} diff --git a/native/swift/Sources/wordpress-api/WordPressAPI.swift b/native/swift/Sources/wordpress-api/WordPressAPI.swift index c271bab69..76e51301e 100644 --- a/native/swift/Sources/wordpress-api/WordPressAPI.swift +++ b/native/swift/Sources/wordpress-api/WordPressAPI.swift @@ -47,6 +47,10 @@ public struct WordPressAPI { self.requestBuilder.plugins() } + public var applicationPasswords: ApplicationPasswordsRequestExecutor { + self.requestBuilder.applicationPasswords() + } + package func perform(request: WpNetworkRequest) async throws -> WpNetworkResponse { try await withCheckedThrowingContinuation { continuation in self.perform(request: request) { result in diff --git a/native/swift/Tests/wordpress-api/Foundation+ExtensionsTests.swift b/native/swift/Tests/wordpress-api/Foundation+ExtensionsTests.swift new file mode 100644 index 000000000..47e5a183c --- /dev/null +++ b/native/swift/Tests/wordpress-api/Foundation+ExtensionsTests.swift @@ -0,0 +1,9 @@ +import XCTest +import WordPressAPI + +final class FoundationExtensionsTests: XCTestCase { + + func testWordPressDateTimeParsing() throws { + XCTAssertNotNil(Date.fromWordPressDate("2024-07-04T01:49:37")) + } +} diff --git a/scripts/setup-test-site.sh b/scripts/setup-test-site.sh index 3aa95ef39..182f50251 100755 --- a/scripts/setup-test-site.sh +++ b/scripts/setup-test-site.sh @@ -80,9 +80,11 @@ wp plugin delete wordpress-importer printf "http://localhost\ntest@example.com\n" ## Create an Application password for the admin user, and store it where it can be used by the test suite wp user application-password create test@example.com test --porcelain + wp user application-password list test@example.com --fields=uuid --format=csv | sed -n '2 p' printf "themedemos\n" ## Create an Application password for a subscriber user, and store it where it can be used by the test suite wp user application-password create themedemos test --porcelain + wp user application-password list themedemos --fields=uuid --format=csv | sed -n '2 p' } >> /tmp/test_credentials ## Used for integration tests diff --git a/wp_api/Cargo.toml b/wp_api/Cargo.toml index d550d2da9..ee4523f3d 100644 --- a/wp_api/Cargo.toml +++ b/wp_api/Cargo.toml @@ -29,6 +29,7 @@ reqwest = "0.12" rstest = { workspace = true } rstest_reuse = { workspace = true } serde_json = { workspace = true } +serial_test = "3.1" sqlx = { version = "0.7", features = [ "chrono", "mysql", "runtime-tokio", "tls-native-tls" ]} tokio = { version = "1.37", features = [ "full" ] } diff --git a/wp_api/build.rs b/wp_api/build.rs index 840f0cea2..747c7b0e9 100644 --- a/wp_api/build.rs +++ b/wp_api/build.rs @@ -30,8 +30,10 @@ struct TestCredentials { site_url: String, admin_username: String, admin_password: String, + admin_password_uuid: String, subscriber_username: String, subscriber_password: String, + subscriber_password_uuid: String, } impl TestCredentials { @@ -44,8 +46,10 @@ impl TestCredentials { site_url: lines[0].to_string(), admin_username: lines[1].to_string(), admin_password: lines[2].to_string(), - subscriber_username: lines[3].to_string(), - subscriber_password: lines[4].to_string(), + admin_password_uuid: lines[3].to_string(), + subscriber_username: lines[4].to_string(), + subscriber_password: lines[5].to_string(), + subscriber_password_uuid: lines[6].to_string(), }); } } @@ -58,14 +62,18 @@ impl TestCredentials { pub const TEST_CREDENTIALS_SITE_URL: &str = "{}"; pub const TEST_CREDENTIALS_ADMIN_USERNAME: &str = "{}"; pub const TEST_CREDENTIALS_ADMIN_PASSWORD: &str = "{}"; +pub const TEST_CREDENTIALS_ADMIN_PASSWORD_UUID: &str = "{}"; pub const TEST_CREDENTIALS_SUBSCRIBER_USERNAME: &str = "{}"; pub const TEST_CREDENTIALS_SUBSCRIBER_PASSWORD: &str = "{}"; +pub const TEST_CREDENTIALS_SUBSCRIBER_PASSWORD_UUID: &str = "{}"; "#, self.site_url, self.admin_username, self.admin_password, + self.admin_password_uuid, self.subscriber_username, - self.subscriber_password + self.subscriber_password, + self.subscriber_password_uuid ) .trim() .to_string() diff --git a/wp_api/src/api_error.rs b/wp_api/src/api_error.rs index 2013c9f22..36f9966c8 100644 --- a/wp_api/src/api_error.rs +++ b/wp_api/src/api_error.rs @@ -63,18 +63,34 @@ pub struct UnrecognizedWpRestError { #[derive(Debug, Deserialize, PartialEq, Eq, uniffi::Error)] pub enum WpRestErrorCode { + #[serde(rename = "rest_application_password_not_found")] + ApplicationPasswordNotFound, + #[serde(rename = "rest_cannot_create_application_passwords")] + CannotCreateApplicationPasswords, #[serde(rename = "rest_cannot_create_user")] CannotCreateUser, #[serde(rename = "rest_cannot_delete_active_plugin")] CannotDeleteActivePlugin, + #[serde(rename = "rest_cannot_delete_application_password")] + CannotDeleteApplicationPassword, + #[serde(rename = "rest_cannot_delete_application_passwords")] + CannotDeleteApplicationPasswords, #[serde(rename = "rest_cannot_edit")] CannotEdit, + #[serde(rename = "rest_cannot_edit_application_password")] + CannotEditApplicationPassword, #[serde(rename = "rest_cannot_edit_roles")] CannotEditRoles, + #[serde(rename = "rest_cannot_introspect_app_password_for_non_authenticated_user")] + CannotIntrospectAppPasswordForNonAuthenticatedUser, #[serde(rename = "rest_cannot_install_plugin")] CannotInstallPlugin, + #[serde(rename = "rest_cannot_list_application_passwords")] + CannotListApplicationPasswords, #[serde(rename = "rest_cannot_manage_plugins")] CannotManagePlugins, + #[serde(rename = "rest_cannot_read_application_password")] + CannotReadApplicationPassword, #[serde(rename = "rest_cannot_view_plugin")] CannotViewPlugin, #[serde(rename = "rest_cannot_view_plugins")] @@ -106,6 +122,17 @@ pub enum WpRestErrorCode { #[serde(rename = "rest_user_invalid_slug")] UserInvalidSlug, // --- + // Untested, because we are unable to create the necessary conditions for them + // --- + #[serde(rename = "application_passwords_disabled")] + ApplicationPasswordsDisabled, + #[serde(rename = "application_passwords_disabled_for_user")] + ApplicationPasswordsDisabledForUser, + #[serde(rename = "rest_cannot_manage_application_passwords")] + CannotManageApplicationPasswords, + #[serde(rename = "rest_no_authenticated_app_password")] + NoAuthenticatedAppPassword, + // --- // Untested, because we believe these errors require multisite // --- #[serde(rename = "rest_cannot_manage_network_plugins")] diff --git a/wp_api/src/application_passwords.rs b/wp_api/src/application_passwords.rs index 958ae83d9..efef38b37 100644 --- a/wp_api/src/application_passwords.rs +++ b/wp_api/src/application_passwords.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use serde::{Deserialize, Serialize}; use wp_contextual::WpContextual; @@ -53,6 +55,12 @@ pub struct ApplicationPasswordUuid { pub uuid: String, } +impl Display for ApplicationPasswordUuid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.uuid) + } +} + #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, uniffi::Record)] #[serde(transparent)] pub struct ApplicationPasswordAppId { @@ -65,3 +73,33 @@ pub struct IpAddress { #[serde(alias = "last_ip")] pub value: String, } + +#[derive(Debug, Serialize, uniffi::Record)] +pub struct ApplicationPasswordCreateParams { + /// A UUID provided by the application to uniquely identify it. + /// It is recommended to use an UUID v5 with the URL or DNS namespace. + pub app_id: Option, + /// The name of the application password. + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize, uniffi::Record)] +pub struct ApplicationPasswordDeleteResponse { + pub deleted: bool, + pub previous: ApplicationPasswordWithEditContext, +} + +#[derive(Debug, Serialize, Deserialize, uniffi::Record)] +pub struct ApplicationPasswordDeleteAllResponse { + pub deleted: bool, + pub count: i32, +} + +#[derive(Debug, Serialize, uniffi::Record)] +pub struct ApplicationPasswordUpdateParams { + /// A UUID provided by the application to uniquely identify it. + /// It is recommended to use an UUID v5 with the URL or DNS namespace. + pub app_id: Option, + /// The name of the application password. + pub name: String, +} diff --git a/wp_api/src/request/endpoint/application_passwords_endpoint.rs b/wp_api/src/request/endpoint/application_passwords_endpoint.rs index e56381534..b3d459d44 100644 --- a/wp_api/src/request/endpoint/application_passwords_endpoint.rs +++ b/wp_api/src/request/endpoint/application_passwords_endpoint.rs @@ -1,13 +1,30 @@ use wp_derive_request_builder::WpDerivedRequest; -use crate::application_passwords::SparseApplicationPasswordField; +use crate::application_passwords::{ + ApplicationPasswordCreateParams, ApplicationPasswordDeleteAllResponse, + ApplicationPasswordDeleteResponse, ApplicationPasswordUpdateParams, ApplicationPasswordUuid, + ApplicationPasswordWithEditContext, ApplicationPasswordWithEmbedContext, + ApplicationPasswordWithViewContext, SparseApplicationPassword, SparseApplicationPasswordField, +}; use crate::users::UserId; #[derive(WpDerivedRequest)] #[SparseField(SparseApplicationPasswordField)] enum ApplicationPasswordsRequest { - #[contextual_get(url = "/users//application-passwords", output = Vec)] + #[post(url = "/users//application-passwords", params = &ApplicationPasswordCreateParams, output = ApplicationPasswordWithEditContext)] + Create, + #[delete(url = "/users//application-passwords/", output = ApplicationPasswordDeleteResponse)] + Delete, + #[delete(url = "/users//application-passwords", output = ApplicationPasswordDeleteAllResponse)] + DeleteAll, + #[contextual_get(url = "/users//application-passwords", output = Vec)] List, + #[contextual_get(url = "/users//application-passwords/", output = SparseApplicationPassword)] + Retrieve, + #[contextual_get(url = "/users//application-passwords/introspect", output = SparseApplicationPassword)] + RetrieveCurrent, + #[post(url = "/users//application-passwords/", params = &ApplicationPasswordUpdateParams, output = ApplicationPasswordWithEditContext)] + Update, } #[cfg(test)] @@ -23,6 +40,35 @@ mod tests { use rstest::*; use std::sync::Arc; + #[rstest] + fn create_application_password(endpoint: ApplicationPasswordsRequestEndpoint) { + validate_endpoint( + endpoint.create(&UserId(1)), + "/users/1/application-passwords", + ); + } + + #[rstest] + fn delete_single_application_password(endpoint: ApplicationPasswordsRequestEndpoint) { + validate_endpoint( + endpoint.delete( + &UserId(2), + &ApplicationPasswordUuid { + uuid: "584a87d5-4f18-4c33-a315-4c05ed1fc485".to_string(), + }, + ), + "/users/2/application-passwords/584a87d5-4f18-4c33-a315-4c05ed1fc485", + ); + } + + #[rstest] + fn delete_all_application_passwords(endpoint: ApplicationPasswordsRequestEndpoint) { + validate_endpoint( + endpoint.delete_all(&UserId(1)), + "/users/1/application-passwords", + ); + } + #[rstest] fn list_application_passwords_with_edit_context(endpoint: ApplicationPasswordsRequestEndpoint) { validate_endpoint( @@ -64,6 +110,77 @@ mod tests { ); } + #[rstest] + fn retrieve_current_application_passwords_with_edit_context( + endpoint: ApplicationPasswordsRequestEndpoint, + ) { + validate_endpoint( + endpoint.retrieve_current_with_edit_context(&UserId(2)), + "/users/2/application-passwords/introspect?context=edit", + ); + } + + #[rstest] + #[case(WpContext::Edit, &[SparseApplicationPasswordField::Uuid], "/users/2/application-passwords/introspect?context=edit&_fields=uuid")] + #[case(WpContext::View, &[SparseApplicationPasswordField::Uuid, SparseApplicationPasswordField::Name], "/users/2/application-passwords/introspect?context=view&_fields=uuid%2Cname")] + fn filter_retrieve_current_application_passwords( + endpoint: ApplicationPasswordsRequestEndpoint, + #[case] context: WpContext, + #[case] fields: &[SparseApplicationPasswordField], + #[case] expected_path: &str, + ) { + validate_endpoint( + endpoint.filter_retrieve_current(&UserId(2), context, fields), + expected_path, + ); + } + + #[rstest] + fn retrieve_application_passwords_with_embed_context( + endpoint: ApplicationPasswordsRequestEndpoint, + ) { + validate_endpoint( + endpoint.retrieve_with_embed_context( + &UserId(2), + &ApplicationPasswordUuid { + uuid: "584a87d5-4f18-4c33-a315-4c05ed1fc485".to_string(), + }, + ), + "/users/2/application-passwords/584a87d5-4f18-4c33-a315-4c05ed1fc485?context=embed", + ); + } + + #[rstest] + #[case(WpContext::Edit, &[SparseApplicationPasswordField::Uuid], "/users/2/application-passwords/584a87d5-4f18-4c33-a315-4c05ed1fc485?context=edit&_fields=uuid")] + #[case(WpContext::View, &[SparseApplicationPasswordField::Uuid, SparseApplicationPasswordField::Password], "/users/2/application-passwords/584a87d5-4f18-4c33-a315-4c05ed1fc485?context=view&_fields=uuid%2Cpassword")] + fn filter_retrieve_application_passwords( + endpoint: ApplicationPasswordsRequestEndpoint, + #[case] context: WpContext, + #[case] fields: &[SparseApplicationPasswordField], + #[case] expected_path: &str, + ) { + let uuid = ApplicationPasswordUuid { + uuid: "584a87d5-4f18-4c33-a315-4c05ed1fc485".to_string(), + }; + validate_endpoint( + endpoint.filter_retrieve(&UserId(2), &uuid, context, fields), + expected_path, + ); + } + + #[rstest] + fn update_application_password(endpoint: ApplicationPasswordsRequestEndpoint) { + validate_endpoint( + endpoint.update( + &UserId(2), + &ApplicationPasswordUuid { + uuid: "584a87d5-4f18-4c33-a315-4c05ed1fc485".to_string(), + }, + ), + "/users/2/application-passwords/584a87d5-4f18-4c33-a315-4c05ed1fc485", + ); + } + #[fixture] fn endpoint(fixture_api_base_url: Arc) -> ApplicationPasswordsRequestEndpoint { ApplicationPasswordsRequestEndpoint::new(fixture_api_base_url) diff --git a/wp_api/tests/integration_test_common.rs b/wp_api/tests/integration_test_common.rs index 94236b281..ca7301d61 100644 --- a/wp_api/tests/integration_test_common.rs +++ b/wp_api/tests/integration_test_common.rs @@ -47,14 +47,23 @@ pub fn request_builder_as_subscriber() -> WpRequestBuilder { .expect("Site url is generated by our tooling") } +pub fn request_builder_as_unauthenticated() -> WpRequestBuilder { + WpRequestBuilder::new( + TEST_CREDENTIALS_SITE_URL.to_string(), + WpAuthentication::None, + Arc::new(AsyncWpNetworking::default()), + ) + .expect("Site url is generated by our tooling") +} + pub trait AssertWpError { fn assert_wp_error(self, expected_error_code: WpRestErrorCode); } impl AssertWpError for Result { fn assert_wp_error(self, expected_error_code: WpRestErrorCode) { - let expected_status_code = - expected_status_code_for_wp_rest_error_code(&expected_error_code); + let expected_status_codes = + expected_status_codes_for_wp_rest_error_code(&expected_error_code); let err = self.unwrap_err(); if let WpApiError::RestError { rest_error: @@ -71,10 +80,12 @@ impl AssertWpError for Result { "Incorrect error code. Expected '{:?}', found '{:?}'. Response was: '{:?}'", expected_error_code, error_code, response ); - assert_eq!( - expected_status_code, status_code, - "Incorrect status code. Expected '{:?}', found '{:?}'. Response was: '{:?}'", - expected_status_code, status_code, response + assert!( + expected_status_codes.contains(&status_code), + "Incorrect status code. Expected one of '{:?}', found '{:?}'. Response was: '{:?}'", + expected_status_codes, + status_code, + response ); } else if let WpApiError::RestError { rest_error: WpRestErrorWrapper::Unrecognized(unrecognized_error), @@ -92,39 +103,51 @@ impl AssertWpError for Result { } } -fn expected_status_code_for_wp_rest_error_code(error_code: &WpRestErrorCode) -> u16 { +fn expected_status_codes_for_wp_rest_error_code(error_code: &WpRestErrorCode) -> &[u16] { match error_code { - WpRestErrorCode::CannotActivatePlugin => 403, - WpRestErrorCode::CannotCreateUser => 403, - WpRestErrorCode::CannotDeactivatePlugin => 403, - WpRestErrorCode::CannotDeleteActivePlugin => 400, - WpRestErrorCode::CannotEdit => 403, - WpRestErrorCode::CannotEditRoles => 403, - WpRestErrorCode::CannotInstallPlugin => 403, - WpRestErrorCode::CannotManageNetworkPlugins => 403, - WpRestErrorCode::CannotManagePlugins => 403, - WpRestErrorCode::CannotViewPlugin => 403, - WpRestErrorCode::CannotViewPlugins => 403, - WpRestErrorCode::ForbiddenContext => 403, - WpRestErrorCode::ForbiddenOrderBy => 403, - WpRestErrorCode::ForbiddenWho => 403, - WpRestErrorCode::NetworkOnlyPlugin => 400, - WpRestErrorCode::PluginNotFound => 404, - WpRestErrorCode::InvalidParam => 400, - WpRestErrorCode::TrashNotSupported => 501, - WpRestErrorCode::Unauthorized => 401, - WpRestErrorCode::UserCannotDelete => 403, - WpRestErrorCode::UserCannotView => 403, - WpRestErrorCode::UserCreate => 500, - WpRestErrorCode::UserExists => 400, - WpRestErrorCode::UserInvalidArgument => 400, - WpRestErrorCode::UserInvalidEmail => 400, - WpRestErrorCode::UserInvalidId => 404, - WpRestErrorCode::UserInvalidPassword => 400, - WpRestErrorCode::UserInvalidReassign => 400, - WpRestErrorCode::UserInvalidRole => 400, - WpRestErrorCode::UserInvalidSlug => 400, - WpRestErrorCode::UserInvalidUsername => 400, + WpRestErrorCode::ApplicationPasswordsDisabled => &[501], + WpRestErrorCode::ApplicationPasswordsDisabledForUser => &[501], + WpRestErrorCode::ApplicationPasswordNotFound => &[404], + WpRestErrorCode::CannotCreateApplicationPasswords => &[403], + WpRestErrorCode::CannotActivatePlugin => &[403], + WpRestErrorCode::CannotCreateUser => &[403], + WpRestErrorCode::CannotDeactivatePlugin => &[403], + WpRestErrorCode::CannotDeleteActivePlugin => &[400], + WpRestErrorCode::CannotDeleteApplicationPassword => &[403], + WpRestErrorCode::CannotDeleteApplicationPasswords => &[403], + WpRestErrorCode::CannotManageApplicationPasswords => &[401, 403], + WpRestErrorCode::CannotEdit => &[403], + WpRestErrorCode::CannotEditApplicationPassword => &[403], + WpRestErrorCode::CannotEditRoles => &[403], + WpRestErrorCode::CannotIntrospectAppPasswordForNonAuthenticatedUser => &[401, 403], + WpRestErrorCode::CannotInstallPlugin => &[403], + WpRestErrorCode::CannotListApplicationPasswords => &[403], + WpRestErrorCode::CannotManageNetworkPlugins => &[403], + WpRestErrorCode::CannotManagePlugins => &[403], + WpRestErrorCode::CannotReadApplicationPassword => &[403], + WpRestErrorCode::CannotViewPlugin => &[403], + WpRestErrorCode::CannotViewPlugins => &[403], + WpRestErrorCode::ForbiddenContext => &[403], + WpRestErrorCode::ForbiddenOrderBy => &[403], + WpRestErrorCode::ForbiddenWho => &[403], + WpRestErrorCode::NetworkOnlyPlugin => &[400], + WpRestErrorCode::NoAuthenticatedAppPassword => &[401], + WpRestErrorCode::PluginNotFound => &[404], + WpRestErrorCode::InvalidParam => &[400], + WpRestErrorCode::TrashNotSupported => &[501], + WpRestErrorCode::Unauthorized => &[401], + WpRestErrorCode::UserCannotDelete => &[403], + WpRestErrorCode::UserCannotView => &[403], + WpRestErrorCode::UserCreate => &[500], + WpRestErrorCode::UserExists => &[400], + WpRestErrorCode::UserInvalidArgument => &[400], + WpRestErrorCode::UserInvalidEmail => &[400], + WpRestErrorCode::UserInvalidId => &[404], + WpRestErrorCode::UserInvalidPassword => &[400], + WpRestErrorCode::UserInvalidReassign => &[400], + WpRestErrorCode::UserInvalidRole => &[400], + WpRestErrorCode::UserInvalidSlug => &[400], + WpRestErrorCode::UserInvalidUsername => &[400], } } @@ -220,3 +243,13 @@ impl AssertResponse for Result { self.unwrap() } } + +pub fn run_wp_cli_command(args: impl AsRef) -> std::process::ExitStatus { + Command::new("make") + .arg("-C") + .arg("../") + .arg("run-wp-cli-command") + .arg(format!("ARGS={}", args.as_ref())) + .status() + .expect("Failed to run wp-cli command") +} diff --git a/wp_api/tests/test_application_passwords_err.rs b/wp_api/tests/test_application_passwords_err.rs new file mode 100644 index 000000000..cc6245a6a --- /dev/null +++ b/wp_api/tests/test_application_passwords_err.rs @@ -0,0 +1,154 @@ +use integration_test_common::{request_builder_as_subscriber, request_builder_as_unauthenticated}; +use rstest::*; +use serial_test::parallel; +use wp_api::application_passwords::{ + ApplicationPasswordCreateParams, ApplicationPasswordUpdateParams, ApplicationPasswordUuid, +}; +use wp_api::WpRestErrorCode; + +use crate::integration_test_common::{ + request_builder, AssertWpError, FIRST_USER_ID, SECOND_USER_ID, + TEST_CREDENTIALS_ADMIN_PASSWORD_UUID, +}; + +pub mod integration_test_common; +pub mod reusable_test_cases; +pub mod wp_db; + +#[rstest] +#[tokio::test] +#[parallel] +async fn list_application_passwords_err_cannot_list_application_passwords() { + // Second user (subscriber) doesn't have access to the first users' application passwords + request_builder_as_subscriber() + .application_passwords() + .list_with_edit_context(&FIRST_USER_ID) + .await + .assert_wp_error(WpRestErrorCode::CannotListApplicationPasswords); +} + +#[rstest] +#[tokio::test] +#[parallel] +async fn retrieve_application_password_err_cannot_read_application_password() { + // Second user (subscriber) doesn't have access to the first users' application passwords + request_builder_as_subscriber() + .application_passwords() + .retrieve_with_edit_context( + &FIRST_USER_ID, + &ApplicationPasswordUuid { + uuid: FIRST_USER_ID.to_string(), + }, + ) + .await + .assert_wp_error(WpRestErrorCode::CannotReadApplicationPassword); +} + +#[rstest] +#[tokio::test] +#[parallel] +async fn create_application_password_err_cannot_create_application_passwords() { + // Second user (subscriber) can not create an application password for the first user + request_builder_as_subscriber() + .application_passwords() + .create( + &FIRST_USER_ID, + &ApplicationPasswordCreateParams { + app_id: None, + name: "foo".to_string(), + }, + ) + .await + .assert_wp_error(WpRestErrorCode::CannotCreateApplicationPasswords); +} + +#[rstest] +#[tokio::test] +#[parallel] +async fn update_application_password_err_cannot_edit_application_password() { + // Second user (subscriber) can not update an application password of the first user + request_builder_as_subscriber() + .application_passwords() + .update( + &FIRST_USER_ID, + &ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_ADMIN_PASSWORD_UUID.to_string(), + }, + &ApplicationPasswordUpdateParams { + app_id: None, + name: "foo".to_string(), + }, + ) + .await + .assert_wp_error(WpRestErrorCode::CannotEditApplicationPassword); +} + +#[rstest] +#[tokio::test] +#[parallel] +async fn delete_application_password_err_cannot_delete_application_password() { + // Second user (subscriber) can not delete an application password of the first user + request_builder_as_subscriber() + .application_passwords() + .delete( + &FIRST_USER_ID, + &ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_ADMIN_PASSWORD_UUID.to_string(), + }, + ) + .await + .assert_wp_error(WpRestErrorCode::CannotDeleteApplicationPassword); +} + +#[rstest] +#[tokio::test] +#[parallel] +async fn delete_application_passwords_err_cannot_delete_application_passwords() { + // Second user (subscriber) can not delete all application passwords of the first user + request_builder_as_subscriber() + .application_passwords() + .delete_all(&FIRST_USER_ID) + .await + .assert_wp_error(WpRestErrorCode::CannotDeleteApplicationPasswords); +} + +#[rstest] +#[tokio::test] +#[parallel] +async fn retrieve_application_password_err_cannot_introspect_app_password_for_non_authenticated_user_401( +) { + // Unauthenticated user can not retrieve the current application password for the second user + request_builder_as_unauthenticated() + .application_passwords() + .retrieve_current_with_edit_context(&SECOND_USER_ID) + .await + .assert_wp_error(WpRestErrorCode::CannotIntrospectAppPasswordForNonAuthenticatedUser); +} + +#[rstest] +#[tokio::test] +#[parallel] +async fn retrieve_application_password_err_cannot_introspect_app_password_for_another_user_403() { + // First user can not retrieve the current application password for the second user + request_builder() + .application_passwords() + .retrieve_current_with_edit_context(&SECOND_USER_ID) + .await + .assert_wp_error(WpRestErrorCode::CannotIntrospectAppPasswordForNonAuthenticatedUser); +} + +#[rstest] +#[tokio::test] +#[parallel] +async fn retrieve_application_password_err_application_password_not_found() { + request_builder() + .application_passwords() + .retrieve_with_edit_context( + &FIRST_USER_ID, + &ApplicationPasswordUuid { + uuid: "foo".to_string(), + }, + ) + .await + .assert_wp_error(WpRestErrorCode::ApplicationPasswordNotFound); +} diff --git a/wp_api/tests/test_application_passwords_immut.rs b/wp_api/tests/test_application_passwords_immut.rs index 056af5260..1eb466f31 100644 --- a/wp_api/tests/test_application_passwords_immut.rs +++ b/wp_api/tests/test_application_passwords_immut.rs @@ -1,11 +1,16 @@ +use integration_test_common::request_builder_as_subscriber; use rstest::*; use rstest_reuse::{self, apply, template}; -use wp_api::application_passwords::{SparseApplicationPassword, SparseApplicationPasswordField}; +use serial_test::parallel; +use wp_api::application_passwords::{ + ApplicationPasswordUuid, SparseApplicationPassword, SparseApplicationPasswordField, +}; use wp_api::users::UserId; use wp_api::WpContext; use crate::integration_test_common::{ request_builder, AssertResponse, FIRST_USER_ID, SECOND_USER_ID, + TEST_CREDENTIALS_ADMIN_PASSWORD_UUID, TEST_CREDENTIALS_SUBSCRIBER_PASSWORD_UUID, }; pub mod integration_test_common; @@ -13,7 +18,8 @@ pub mod reusable_test_cases; #[apply(filter_fields_cases)] #[tokio::test] -async fn filter_application_passwords( +#[parallel] +async fn filter_list_application_passwords( #[values(FIRST_USER_ID, SECOND_USER_ID)] user_id: UserId, #[case] fields: &[SparseApplicationPasswordField], ) { @@ -26,8 +32,42 @@ async fn filter_application_passwords( .for_each(|p| validate_sparse_application_password_fields(p, fields)); } +#[apply(filter_fields_cases)] +#[tokio::test] +#[parallel] +async fn filter_retrieve_application_password(#[case] fields: &[SparseApplicationPasswordField]) { + let p = request_builder() + .application_passwords() + .filter_retrieve( + &FIRST_USER_ID, + &ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_ADMIN_PASSWORD_UUID.to_string(), + }, + WpContext::Edit, + fields, + ) + .await + .assert_response(); + validate_sparse_application_password_fields(&p, fields); +} + +#[apply(filter_fields_cases)] +#[tokio::test] +#[parallel] +async fn filter_retrieve_current_application_password( + #[case] fields: &[SparseApplicationPasswordField], +) { + let p = request_builder() + .application_passwords() + .filter_retrieve_current(&FIRST_USER_ID, WpContext::Edit, fields) + .await + .assert_response(); + validate_sparse_application_password_fields(&p, fields); +} + #[rstest] #[tokio::test] +#[parallel] async fn list_application_passwords_with_edit_context( #[values(FIRST_USER_ID, SECOND_USER_ID)] user_id: UserId, ) { @@ -40,6 +80,7 @@ async fn list_application_passwords_with_edit_context( #[rstest] #[tokio::test] +#[parallel] async fn list_application_passwords_with_embed_context( #[values(FIRST_USER_ID, SECOND_USER_ID)] user_id: UserId, ) { @@ -52,6 +93,7 @@ async fn list_application_passwords_with_embed_context( #[rstest] #[tokio::test] +#[parallel] async fn list_application_passwords_with_view_context( #[values(FIRST_USER_ID, SECOND_USER_ID)] user_id: UserId, ) { @@ -65,6 +107,7 @@ async fn list_application_passwords_with_view_context( // TODO: This might not be a good test case to keep, but it's helpful during initial implementation // to ensure that the ip address is properly parsed #[tokio::test] +#[parallel] async fn list_application_passwords_ensure_last_ip() { let list = request_builder() .application_passwords() @@ -74,6 +117,96 @@ async fn list_application_passwords_ensure_last_ip() { assert!(list.first().unwrap().last_ip.is_some()); } +#[tokio::test] +#[parallel] +async fn retrieve_current_application_passwords_with_edit_context() { + let a = request_builder() + .application_passwords() + .retrieve_current_with_edit_context(&FIRST_USER_ID) + .await + .assert_response(); + assert_eq!( + a.uuid, + ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_ADMIN_PASSWORD_UUID.to_string() + } + ); +} + +#[tokio::test] +#[parallel] +async fn retrieve_current_application_passwords_with_embed_context() { + let a = request_builder_as_subscriber() + .application_passwords() + .retrieve_current_with_embed_context(&SECOND_USER_ID) + .await + .assert_response(); + assert_eq!( + a.uuid, + ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_SUBSCRIBER_PASSWORD_UUID.to_string() + } + ); +} + +#[tokio::test] +#[parallel] +async fn retrieve_current_application_passwords_with_view_context() { + let a = request_builder() + .application_passwords() + .retrieve_current_with_view_context(&FIRST_USER_ID) + .await + .assert_response(); + assert_eq!( + a.uuid, + ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_ADMIN_PASSWORD_UUID.to_string() + } + ); +} + +#[tokio::test] +#[parallel] +async fn retrieve_application_passwords_with_edit_context() { + let uuid = ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_ADMIN_PASSWORD_UUID.to_string(), + }; + let a = request_builder() + .application_passwords() + .retrieve_with_edit_context(&FIRST_USER_ID, &uuid) + .await + .assert_response(); + assert_eq!(a.uuid, uuid); +} + +#[tokio::test] +#[parallel] +async fn retrieve_application_passwords_with_embed_context() { + let uuid = ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_ADMIN_PASSWORD_UUID.to_string(), + }; + let a = request_builder() + .application_passwords() + .retrieve_with_embed_context(&FIRST_USER_ID, &uuid) + .await + .assert_response(); + assert_eq!(a.uuid, uuid); +} + +#[tokio::test] +#[parallel] +async fn retrieve_application_passwords_with_view_context() { + let uuid = ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_SUBSCRIBER_PASSWORD_UUID.to_string(), + }; + let a = request_builder() + .application_passwords() + .retrieve_with_view_context(&SECOND_USER_ID, &uuid) + .await + .assert_response(); + assert_eq!(a.uuid, uuid); +} + fn validate_sparse_application_password_fields( app_password: &SparseApplicationPassword, fields: &[SparseApplicationPasswordField], diff --git a/wp_api/tests/test_application_passwords_mut.rs b/wp_api/tests/test_application_passwords_mut.rs new file mode 100644 index 000000000..4212875bc --- /dev/null +++ b/wp_api/tests/test_application_passwords_mut.rs @@ -0,0 +1,175 @@ +use integration_test_common::TEST_CREDENTIALS_ADMIN_PASSWORD_UUID; +use serial_test::serial; +use wp_api::{ + application_passwords::{ + ApplicationPasswordCreateParams, ApplicationPasswordUpdateParams, ApplicationPasswordUuid, + }, + users::UserId, +}; +use wp_db::DbUserMeta; + +use crate::integration_test_common::{ + request_builder, AssertResponse, FIRST_USER_ID, SECOND_USER_ID, + TEST_CREDENTIALS_SUBSCRIBER_PASSWORD_UUID, +}; + +pub mod integration_test_common; +pub mod wp_db; + +#[tokio::test] +#[serial] +async fn create_application_password() { + wp_db::run_and_restore(|mut db| async move { + let password_name = "IntegrationTest"; + // Assert that the application password name is not in DB + assert!( + !db_application_password_meta_for_user(&mut db, &SECOND_USER_ID) + .await + .unwrap() + .meta_value + .contains(password_name) + ); + + // Create an application password using the API + let params = ApplicationPasswordCreateParams { + app_id: None, + name: password_name.to_string(), + }; + let created_application_password = request_builder() + .application_passwords() + .create(&SECOND_USER_ID, ¶ms) + .await + .assert_response(); + + // Assert that the application password is in DB + let db_user_meta_after_update = + db_application_password_meta_for_user(&mut db, &SECOND_USER_ID).await; + assert!(db_user_meta_after_update.is_some()); + let meta_value = db_user_meta_after_update.unwrap().meta_value; + assert!(meta_value.contains(password_name)); + assert!(meta_value.contains(&created_application_password.uuid.uuid)); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn update_application_password() { + wp_db::run_and_restore(|mut db| async move { + let password_name = "IntegrationTest"; + // Assert that the application password name is not in DB + assert!( + !db_application_password_meta_for_user(&mut db, &FIRST_USER_ID) + .await + .unwrap() + .meta_value + .contains(password_name) + ); + + // Update the application password to use the new name using the API + let params = ApplicationPasswordUpdateParams { + app_id: None, + name: password_name.to_string(), + }; + let created_application_password = request_builder() + .application_passwords() + .update( + &FIRST_USER_ID, + &ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_ADMIN_PASSWORD_UUID.to_string(), + }, + ¶ms, + ) + .await + .assert_response(); + + // Assert that the application password is in DB + let db_user_meta_after_update = + db_application_password_meta_for_user(&mut db, &FIRST_USER_ID).await; + assert!(db_user_meta_after_update.is_some()); + let meta_value = db_user_meta_after_update.unwrap().meta_value; + assert!(meta_value.contains(password_name)); + assert!(meta_value.contains(&created_application_password.uuid.uuid)); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn delete_single_application_password() { + wp_db::run_and_restore(|mut db| async move { + let uuid = ApplicationPasswordUuid { + uuid: TEST_CREDENTIALS_SUBSCRIBER_PASSWORD_UUID.to_string(), + }; + // Assert that the application password is in DB + assert!( + db_application_password_meta_for_user(&mut db, &SECOND_USER_ID) + .await + .unwrap() + .meta_value + .contains(TEST_CREDENTIALS_SUBSCRIBER_PASSWORD_UUID) + ); + // Delete the user's application passwords using the API and ensure it's successful + let response = request_builder() + .application_passwords() + .delete(&SECOND_USER_ID, &uuid) + .await + .assert_response(); + + // Assert that the application password is deleted and no longer in DB + assert!(response.deleted); + assert_eq!(response.previous.uuid, uuid); + assert!( + !db_application_password_meta_for_user(&mut db, &SECOND_USER_ID) + .await + .unwrap() + .meta_value + .contains(TEST_CREDENTIALS_SUBSCRIBER_PASSWORD_UUID) + ); + }) + .await; +} + +#[tokio::test] +#[serial] +async fn delete_all_application_passwords() { + wp_db::run_and_restore(|mut db| async move { + // Assert that the application password is in DB + assert!( + db_application_password_meta_for_user(&mut db, &SECOND_USER_ID) + .await + .unwrap() + .meta_value + .contains(TEST_CREDENTIALS_SUBSCRIBER_PASSWORD_UUID) + ); + // Delete the user's application passwords using the API and ensure it's successful + let response = request_builder() + .application_passwords() + .delete_all(&SECOND_USER_ID) + .await + .assert_response(); + + // Assert that the application password is deleted and no longer in DB + assert!(response.deleted); + assert_eq!(response.count, 1); + assert!( + !db_application_password_meta_for_user(&mut db, &SECOND_USER_ID) + .await + .unwrap() + .meta_value + .contains(TEST_CREDENTIALS_SUBSCRIBER_PASSWORD_UUID) + ); + }) + .await; +} + +async fn db_application_password_meta_for_user( + db: &mut wp_db::WordPressDb, + user_id: &UserId, +) -> Option { + db.user_meta(user_id.0 as u64) + .await + .unwrap() + .into_iter() + .find(|m| m.meta_key == "_application_passwords") +} diff --git a/wp_api/tests/test_login_immut.rs b/wp_api/tests/test_login_immut.rs index f4184fe13..d2ed5155c 100644 --- a/wp_api/tests/test_login_immut.rs +++ b/wp_api/tests/test_login_immut.rs @@ -1,5 +1,6 @@ use integration_test_common::{AssertResponse, AsyncWpNetworking}; use rstest::rstest; +use serial_test::serial; use std::sync::Arc; use wp_api::login::WpLoginClient; @@ -26,6 +27,7 @@ const ORCHESTREMETROPOLITAIN_AUTH_URL: &str = ORCHESTREMETROPOLITAIN_AUTH_URL )] #[tokio::test] +#[serial] async fn test_login_flow(#[case] site_url: &str, #[case] expected_auth_url: &str) { let client = WpLoginClient::new(Arc::new(AsyncWpNetworking::default())); let url_discovery = client diff --git a/wp_api/tests/test_manual_request_builder_immut.rs b/wp_api/tests/test_manual_request_builder_immut.rs index 4bbe97b52..a95de4d62 100644 --- a/wp_api/tests/test_manual_request_builder_immut.rs +++ b/wp_api/tests/test_manual_request_builder_immut.rs @@ -5,6 +5,7 @@ use integration_test_common::{ use reusable_test_cases::list_users_cases; use rstest::*; use rstest_reuse::{self, apply}; +use serial_test::parallel; use wp_api::{ generate, users::UserWithEditContext, @@ -20,6 +21,7 @@ pub mod reusable_test_cases; #[apply(list_users_cases)] #[tokio::test] +#[parallel] async fn list_users_with_edit_context(#[case] params: UserListParams) { let authentication = WpAuthentication::from_username_and_password( TEST_CREDENTIALS_ADMIN_USERNAME.to_string(), diff --git a/wp_api/tests/test_plugins_immut.rs b/wp_api/tests/test_plugins_immut.rs index b4e41bed6..1eabce0a9 100644 --- a/wp_api/tests/test_plugins_immut.rs +++ b/wp_api/tests/test_plugins_immut.rs @@ -1,5 +1,6 @@ use rstest::*; use rstest_reuse::{self, apply, template}; +use serial_test::parallel; use wp_api::{ generate, plugins::{PluginListParams, PluginSlug, PluginStatus, SparsePlugin, SparsePluginField}, @@ -14,6 +15,7 @@ pub mod integration_test_common; #[apply(filter_fields_cases)] #[tokio::test] +#[parallel] async fn filter_plugins( #[case] fields: &[SparsePluginField], #[values( @@ -34,6 +36,7 @@ async fn filter_plugins( #[apply(filter_fields_cases)] #[tokio::test] +#[parallel] async fn filter_retrieve_plugin( #[case] fields: &[SparsePluginField], #[values(CLASSIC_EDITOR_PLUGIN_SLUG, HELLO_DOLLY_PLUGIN_SLUG)] slug: &str, @@ -53,6 +56,7 @@ async fn filter_retrieve_plugin( #[case(generate!(PluginListParams, (search, Some("foo".to_string())), (status, Some(PluginStatus::Inactive))))] #[trace] #[tokio::test] +#[parallel] async fn list_plugins( #[case] params: PluginListParams, #[values(WpContext::Edit, WpContext::Embed, WpContext::View)] context: WpContext, @@ -87,6 +91,7 @@ async fn list_plugins( #[case(HELLO_DOLLY_PLUGIN_SLUG.into(), "Matt Mullenweg", "http://wordpress.org/plugins/hello-dolly/")] #[trace] #[tokio::test] +#[parallel] async fn retrieve_plugin( #[case] plugin_slug: PluginSlug, #[case] expected_author: &str, diff --git a/wp_api/tests/test_plugins_mut.rs b/wp_api/tests/test_plugins_mut.rs index 1608d3d39..bc5020f0e 100644 --- a/wp_api/tests/test_plugins_mut.rs +++ b/wp_api/tests/test_plugins_mut.rs @@ -1,5 +1,6 @@ use integration_test_common::AssertResponse; use rstest::rstest; +use serial_test::serial; use wp_api::plugins::{PluginCreateParams, PluginSlug, PluginStatus, PluginUpdateParams}; use crate::integration_test_common::{ @@ -11,6 +12,7 @@ pub mod integration_test_common; pub mod wp_db; #[tokio::test] +#[serial] async fn create_plugin() { run_and_restore_wp_content_plugins(|| { wp_db::run_and_restore(|mut _db| async move { @@ -36,6 +38,7 @@ async fn create_plugin() { #[case(PluginSlug::new(CLASSIC_EDITOR_PLUGIN_SLUG.into()), PluginStatus::Inactive)] #[trace] #[tokio::test] +#[serial] async fn update_plugin(#[case] slug: PluginSlug, #[case] new_status: PluginStatus) { run_and_restore_wp_content_plugins(|| { wp_db::run_and_restore(|mut _db| async move { @@ -52,6 +55,7 @@ async fn update_plugin(#[case] slug: PluginSlug, #[case] new_status: PluginStatu } #[tokio::test] +#[serial] async fn delete_plugin() { run_and_restore_wp_content_plugins(|| { wp_db::run_and_restore(|mut _db| async move { diff --git a/wp_api/tests/test_users_err.rs b/wp_api/tests/test_users_err.rs index 94e9b67ce..e0f5eee5f 100644 --- a/wp_api/tests/test_users_err.rs +++ b/wp_api/tests/test_users_err.rs @@ -1,17 +1,15 @@ -use std::sync::Arc; - -use integration_test_common::{AsyncWpNetworking, SECOND_USER_EMAIL, TEST_CREDENTIALS_SITE_URL}; +use integration_test_common::SECOND_USER_EMAIL; use wp_api::{ users::{ UserCreateParams, UserDeleteParams, UserId, UserListParams, UserUpdateParams, WpApiParamUsersHasPublishedPosts, WpApiParamUsersOrderBy, WpApiParamUsersWho, }, - WpAuthentication, WpRestErrorCode, + WpRestErrorCode, }; use crate::integration_test_common::{ - request_builder, request_builder_as_subscriber, AssertWpError, FIRST_USER_ID, SECOND_USER_ID, - SECOND_USER_SLUG, + request_builder, request_builder_as_subscriber, request_builder_as_unauthenticated, + AssertWpError, FIRST_USER_ID, SECOND_USER_ID, SECOND_USER_SLUG, }; pub mod integration_test_common; @@ -163,16 +161,11 @@ async fn retrieve_user_err_user_invalid_id() { #[tokio::test] async fn retrieve_user_err_unauthorized() { - wp_api::WpRequestBuilder::new( - TEST_CREDENTIALS_SITE_URL.to_string(), - WpAuthentication::None, - Arc::new(AsyncWpNetworking::default()), - ) - .expect("Site url is generated by our tooling") - .users() - .retrieve_me_with_edit_context() - .await - .assert_wp_error(WpRestErrorCode::Unauthorized); + request_builder_as_unauthenticated() + .users() + .retrieve_me_with_edit_context() + .await + .assert_wp_error(WpRestErrorCode::Unauthorized); } #[tokio::test] diff --git a/wp_api/tests/test_users_immut.rs b/wp_api/tests/test_users_immut.rs index 2c9abcc8d..15d3844d9 100644 --- a/wp_api/tests/test_users_immut.rs +++ b/wp_api/tests/test_users_immut.rs @@ -1,6 +1,7 @@ use reusable_test_cases::list_users_cases; use rstest::*; use rstest_reuse::{self, apply, template}; +use serial_test::parallel; use wp_api::{ generate, users::{ @@ -19,6 +20,7 @@ pub mod reusable_test_cases; #[apply(filter_fields_cases)] #[tokio::test] +#[parallel] async fn filter_users(#[case] fields: &[SparseUserField]) { request_builder() .users() @@ -31,6 +33,7 @@ async fn filter_users(#[case] fields: &[SparseUserField]) { #[apply(filter_fields_cases)] #[tokio::test] +#[parallel] async fn filter_retrieve_user(#[case] fields: &[SparseUserField]) { let user = request_builder() .users() @@ -42,6 +45,7 @@ async fn filter_retrieve_user(#[case] fields: &[SparseUserField]) { #[apply(filter_fields_cases)] #[tokio::test] +#[parallel] async fn filter_retrieve_current_user(#[case] fields: &[SparseUserField]) { let user = request_builder() .users() @@ -53,6 +57,7 @@ async fn filter_retrieve_current_user(#[case] fields: &[SparseUserField]) { #[apply(list_users_cases)] #[tokio::test] +#[parallel] async fn list_users_with_edit_context(#[case] params: UserListParams) { request_builder() .users() @@ -63,6 +68,7 @@ async fn list_users_with_edit_context(#[case] params: UserListParams) { #[apply(list_users_cases)] #[tokio::test] +#[parallel] async fn list_users_with_embed_context(#[case] params: UserListParams) { request_builder() .users() @@ -73,6 +79,7 @@ async fn list_users_with_embed_context(#[case] params: UserListParams) { #[apply(list_users_cases)] #[tokio::test] +#[parallel] async fn list_users_with_view_context(#[case] params: UserListParams) { request_builder() .users() @@ -84,6 +91,7 @@ async fn list_users_with_view_context(#[case] params: UserListParams) { #[apply(list_users_has_published_posts_cases)] #[trace] #[tokio::test] +#[parallel] async fn list_users_with_edit_context_has_published_posts( #[case] has_published_posts: Option, ) { @@ -100,6 +108,7 @@ async fn list_users_with_edit_context_has_published_posts( #[apply(list_users_has_published_posts_cases)] #[trace] #[tokio::test] +#[parallel] async fn list_users_with_embed_context_has_published_posts( #[case] has_published_posts: Option, ) { @@ -116,6 +125,7 @@ async fn list_users_with_embed_context_has_published_posts( #[apply(list_users_has_published_posts_cases)] #[trace] #[tokio::test] +#[parallel] async fn list_users_with_view_context_has_published_posts( #[case] has_published_posts: Option, ) { @@ -132,6 +142,7 @@ async fn list_users_with_view_context_has_published_posts( #[rstest] #[trace] #[tokio::test] +#[parallel] async fn retrieve_user_with_edit_context(#[values(FIRST_USER_ID, SECOND_USER_ID)] user_id: UserId) { let user = request_builder() .users() @@ -144,6 +155,7 @@ async fn retrieve_user_with_edit_context(#[values(FIRST_USER_ID, SECOND_USER_ID) #[rstest] #[trace] #[tokio::test] +#[parallel] async fn retrieve_user_with_embed_context( #[values(FIRST_USER_ID, SECOND_USER_ID)] user_id: UserId, ) { @@ -158,6 +170,7 @@ async fn retrieve_user_with_embed_context( #[rstest] #[trace] #[tokio::test] +#[parallel] async fn retrieve_user_with_view_context(#[values(FIRST_USER_ID, SECOND_USER_ID)] user_id: UserId) { let user = request_builder() .users() @@ -168,6 +181,7 @@ async fn retrieve_user_with_view_context(#[values(FIRST_USER_ID, SECOND_USER_ID) } #[tokio::test] +#[parallel] async fn retrieve_me_with_edit_context() { let user = request_builder() .users() @@ -179,6 +193,7 @@ async fn retrieve_me_with_edit_context() { } #[tokio::test] +#[parallel] async fn retrieve_me_with_embed_context() { let user = request_builder() .users() @@ -190,6 +205,7 @@ async fn retrieve_me_with_embed_context() { } #[tokio::test] +#[parallel] async fn retrieve_me_with_view_context() { let user = request_builder() .users() diff --git a/wp_api/tests/test_users_mut.rs b/wp_api/tests/test_users_mut.rs index 0e7916870..2144db86b 100644 --- a/wp_api/tests/test_users_mut.rs +++ b/wp_api/tests/test_users_mut.rs @@ -1,4 +1,5 @@ use integration_test_common::AssertResponse; +use serial_test::serial; use wp_api::users::{UserCreateParams, UserDeleteParams, UserUpdateParams}; use wp_db::{DbUser, DbUserMeta}; @@ -8,6 +9,7 @@ pub mod integration_test_common; pub mod wp_db; #[tokio::test] +#[serial] async fn create_user() { wp_db::run_and_restore(|mut db| async move { let username = "t_username"; @@ -35,6 +37,7 @@ async fn create_user() { } #[tokio::test] +#[serial] async fn delete_user() { wp_db::run_and_restore(|mut db| async move { // Delete the user using the API and ensure it's successful @@ -57,6 +60,7 @@ async fn delete_user() { } #[tokio::test] +#[serial] async fn delete_current_user() { wp_db::run_and_restore(|mut db| async move { // Delete the user using the API and ensure it's successful @@ -82,6 +86,7 @@ async fn delete_current_user() { } #[tokio::test] +#[serial] async fn update_user_name() { let new_name = "new_name"; let params = UserUpdateParams { @@ -95,6 +100,7 @@ async fn update_user_name() { } #[tokio::test] +#[serial] async fn update_user_first_name() { let new_first_name = "new_first_name"; let params = UserUpdateParams { @@ -108,6 +114,7 @@ async fn update_user_first_name() { } #[tokio::test] +#[serial] async fn update_user_last_name() { let new_last_name = "new_last_name"; let params = UserUpdateParams { @@ -121,6 +128,7 @@ async fn update_user_last_name() { } #[tokio::test] +#[serial] async fn update_user_email() { let new_email = "new_email@example.com"; let params = UserUpdateParams { @@ -134,6 +142,7 @@ async fn update_user_email() { } #[tokio::test] +#[serial] async fn update_user_url() { let new_url = "https://new_url"; let params = UserUpdateParams { @@ -147,6 +156,7 @@ async fn update_user_url() { } #[tokio::test] +#[serial] async fn update_user_description() { let new_description = "new_description"; let params = UserUpdateParams { @@ -160,6 +170,7 @@ async fn update_user_description() { } #[tokio::test] +#[serial] async fn update_user_nickname() { let new_nickname = "new_nickname"; let params = UserUpdateParams { @@ -173,6 +184,7 @@ async fn update_user_nickname() { } #[tokio::test] +#[serial] async fn update_user_slug() { let new_slug = "new_slug"; let params = UserUpdateParams { @@ -186,6 +198,7 @@ async fn update_user_slug() { } #[tokio::test] +#[serial] async fn update_user_roles() { wp_db::run_and_restore(|_| async move { let new_role = "author"; @@ -205,6 +218,7 @@ async fn update_user_roles() { } #[tokio::test] +#[serial] async fn update_user_password() { wp_db::run_and_restore(|_| async move { let new_password = "new_password";