diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 3959424ff3..240e0d747c 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -80,6 +80,7 @@ dotnet downloaders dsx DWORDLONG +EApp emoji ENDDIALOG ensureandinsert diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index f19f1a7bdc..e21e0fc455 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -31,6 +31,8 @@ The solution uses: - vcpkg for C++ dependencies - NuGet for C++ and .NET dependencies +CI uses `/p:PreferredToolArchitecture=x64` to avoid 32-bit linker memory limits without changing the target architecture. Use the same setting for command-line Release builds. + ### Running/Debugging 1. Deploy solution: Build > Deploy Solution diff --git a/doc/ReleaseNotes.md b/doc/ReleaseNotes.md index c825a17512..41c77e1db0 100644 --- a/doc/ReleaseNotes.md +++ b/doc/ReleaseNotes.md @@ -27,6 +27,11 @@ This change resolves alias failures in non-symlinked scenarios, including cases Because the alias is now created as an executable hardlink in the install location, command aliases remain available and consistent even when symlink creation is skipped. ### Minor Bug Fixes +* Fixed REST search results bypassing locally verifiable package filters and selectors. Extra manifests are retrieved only for complete source result sets of three or fewer packages. Normalized name/publisher criteria remain unvalidated client-side. +* Fixed installed-package matching incorrectly combining names and publishers from different manifest entries. +* Prevented unrestricted REST searches when a source declares all requested selectors unsupported. +* Prevented REST searches from looping indefinitely when continuation tokens repeat. +* Fixed Unicode case-insensitive prefix matching when case folding changes character lengths. * Fixed an issue where `winget search --id ` could fail to return a Microsoft Store package unless `--exact` was also provided. * Updated NUnit to v4 * Fixed a crash (`0x8000ffff`) when using `--disable-interactivity` with the Resume experimental feature enabled during install operations. diff --git a/src/AppInstallerCLITests/CompositeSource.cpp b/src/AppInstallerCLITests/CompositeSource.cpp index 8160c8df5e..0869dc650d 100644 --- a/src/AppInstallerCLITests/CompositeSource.cpp +++ b/src/AppInstallerCLITests/CompositeSource.cpp @@ -4,10 +4,12 @@ #include "TestCommon.h" #include "TestSource.h" #include "TestHooks.h" +#include "TestRestRequestHandler.h" #include #include #include #include +#include #include #include #include @@ -438,6 +440,64 @@ TEST_CASE("CompositeSource_ProductCode_Available", "[CompositeSource]") REQUIRE(result.Matches[0].Package->GetAvailable()[0]->GetVersionKeys().size() == 1); } +TEST_CASE("Package_MatrixProperty_NameAndPublisher", "[CompositeSource]") +{ + bool hasNames = GENERATE(false, true); + bool hasPublishers = GENERATE(false, true); + CAPTURE(hasNames, hasPublishers); + Manifest::Manifest manifest; + manifest.Version = "1.0"; + auto& localization = manifest.Localizations.emplace_back(); + if (hasNames) + { + manifest.DefaultLocalization.Add("First Name"); + localization.Add("Second Name"); + } + if (hasPublishers) + { + manifest.DefaultLocalization.Add("First Publisher"); + localization.Add("Second Publisher"); + } + auto package = TestPackage::Make(std::vector{ manifest }); + std::vector> expected; + if (hasNames && hasPublishers) + { + expected = { + { "first name", "first publisher" }, + { "first name", "second publisher" }, + { "second name", "first publisher" }, + { "second name", "second publisher" }, + }; + } + REQUIRE(package->GetMatrixProperty(PackageMatrixProperty::NormalizedNameAndPublisher) == expected); + REQUIRE_THROWS_HR(package->GetMatrixProperty(static_cast(-1)), E_UNEXPECTED); +} + +TEST_CASE("CompositeSource_NameAndPublisher_InvalidMatrixRow", "[CompositeSource]") +{ + struct TestMatrixPackage : TestPackage + { + using TestPackage::TestPackage; + + std::vector> GetMatrixProperty(PackageMatrixProperty) const override + { + return Rows; + } + + std::vector> Rows; + }; + + size_t columnCount = GENERATE(size_t{ 0 }, size_t{ 1 }, size_t{ 3 }); + CAPTURE(columnCount); + CompositeTestSetup setup{ CompositeSearchBehavior::AvailablePackages }; + auto package = std::make_shared(std::vector{ MakeDefaultManifest() }, setup.Available); + package->Rows.emplace_back(columnCount, "value"); + auto available = setup.MakeAvailable().ToPackage(); + available->Available[0] = package; + setup.Available->Everything.Matches.emplace_back(available, Criteria()); + REQUIRE_THROWS_HR(setup.Search(), E_UNEXPECTED); +} + TEST_CASE("CompositeSource_NameAndPublisher_Match", "[CompositeSource]") { CompositeTestSetup setup; @@ -2037,3 +2097,229 @@ TEST_CASE("CompositeSource_MappedVersions_ProperSorting", "[CompositeSource]") REQUIRE(installedVersions[0].Version == versionMapped2); REQUIRE(installedVersions[1].Version == versionMapped1); } + +struct RestCorrelationTestSetup : CompositeWithTrackingTestSetup +{ + web::json::value SearchResponse = web::json::value::parse(LR"({ + "Data": [{ + "PackageIdentifier": "Foo.Bar", "PackageName": "Legacy App", "Publisher": "Legacy Publisher", + "Versions": [{ "PackageVersion": "Unknown" }] + }] + })"); + web::json::value ManifestResponse = web::json::value::parse(LR"({ + "Data": { + "PackageIdentifier": "Foo.Bar", + "Versions": [{ + "PackageVersion": "1.0.0", + "DefaultLocale": { + "PackageLocale": "en-US", "PackageName": "New App", "Publisher": "New Publisher", "Moniker": "tool", + "License": "MIT", "ShortDescription": "Example application" + }, + "Installers": [{ + "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/installer.exe", + "InstallerSha256": "011048877dfaef109801b3f3ab2b60afc74f3fc4f7b3430e0c897f5da1df84b6" + }] + }] + } + })"); + size_t ManifestRequests = 0; + + RestCorrelationTestSetup(CompositeSearchBehavior behavior) + { + namespace RepositoryRest = AppInstaller::Repository::Rest; + auto handler = std::make_shared( + [this](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + response.set_status_code(web::http::status_codes::OK); + response.set_body(SearchResponse); + } + else if (request.method() == web::http::methods::GET) + { + ++ManifestRequests; + response.set_status_code(web::http::status_codes::OK); + response.set_body(ManifestResponse); + } + return pplx::task_from_result(response); + }); + Http::HttpClientHelper helper{ handler }; + SourceDetails details; + details.Identifier = "RestCorrelationTestSource"; + auto source = std::make_shared(details, SourceInformation{}, + RepositoryRest::RestClient::Create("https://restsource.com/api", {}, {}, helper, + RepositoryRest::Schema::IRestClient::Information{ details.Identifier, { "1.4.0" } })); + Composite = CompositeSource{ "*RestTests" }; + Composite.SetInstalledSource(Source{ Installed }, behavior); + Composite.AddAvailableSource(Source{ source }); + } +}; + +TEST_CASE("CompositeSource_RestRetrieval_InstalledVersion", "[RestSource][CompositeSource][RestRetrievalRegression]") +{ + auto [manifestHasArpRanges, onlyLatestVersion] = GENERATE( + std::make_pair(false, false), std::make_pair(false, true), std::make_pair(true, false)); + bool retrieveBeforeCorrelation = GENERATE(false, true); + CAPTURE(manifestHasArpRanges, onlyLatestVersion, retrieveBeforeCorrelation); + RestCorrelationTestSetup setup{ CompositeSearchBehavior::AvailablePackages }; + auto& searchVersion = setup.SearchResponse[L"Data"][0][L"Versions"][0]; + searchVersion[L"ProductCodes"][0] = web::json::value::string(L"search.code"); + searchVersion[L"AppsAndFeaturesEntryVersions"] = web::json::value::array( + { web::json::value::string(L"1.0.0"), web::json::value::string(L"2.0.0") }); + auto first = setup.ManifestResponse[L"Data"][L"Versions"][0]; + auto second = first; + first[L"PackageVersion"] = web::json::value::string(manifestHasArpRanges ? L"10.0.0" : L"1.0.0"); + second[L"PackageVersion"] = web::json::value::string(manifestHasArpRanges ? L"20.0.0" : L"2.0.0"); + if (manifestHasArpRanges) + { + first[L"Installers"][0][L"AppsAndFeaturesEntries"][0][L"DisplayVersion"] = web::json::value::string(L"1.0.0"); + second[L"Installers"][0][L"AppsAndFeaturesEntries"][0][L"DisplayVersion"] = web::json::value::string(L"2.0.0"); + } + setup.ManifestResponse[L"Data"][L"Versions"] = onlyLatestVersion ? + web::json::value::array({ second }) : web::json::value::array({ first, second }); + auto installed = setup.MakeInstalled().WithVersion("1.0.0").WithPC("search.code") + .WithMetadata(PackageVersionMetadata::InstalledType, "exe").ToPackage(); + setup.Installed->SearchFunction = [&](const SearchRequest& request) + { + SearchResult result; + if (request.Purpose == SearchPurpose::CorrelationToInstalled && + SearchRequestIncludes(request.Inclusions, PackageMatchField::ProductCode, MatchType::Exact, "search.code")) + { + result.Matches.emplace_back(installed, PackageMatchFilter{ PackageMatchField::ProductCode, MatchType::Exact, "search.code" }); + } + return result; + }; + SearchRequest request; + request.Filters.emplace_back(retrieveBeforeCorrelation ? PackageMatchField::Moniker : PackageMatchField::Name, + MatchType::Exact, retrieveBeforeCorrelation ? "tool"sv : "Legacy App"sv); + auto result = setup.Composite.Search(request); + REQUIRE(result.Failures.empty()); + REQUIRE(result.Matches.size() == 1); + auto installedVersion = GetInstalledVersion(result.Matches[0].Package); + REQUIRE(installedVersion); + CHECK(installedVersion->GetProperty(PackageVersionProperty::Version).get() == (manifestHasArpRanges ? "10.0.0" : "1.0.0")); + auto latestAvailable = GetAvailableVersionsForInstalledVersion(result.Matches[0].Package)->GetLatestVersion(); + REQUIRE(latestAvailable); + REQUIRE(latestAvailable->GetProperty(PackageVersionProperty::Version).get() == (manifestHasArpRanges ? "20.0.0" : "2.0.0")); + PinningData::PinStateEvaluator evaluator{ PinBehavior::IgnorePins, {}, installedVersion }; + CHECK(evaluator.IsUpdate(latestAvailable)); + REQUIRE(setup.ManifestRequests == 1); +} + +TEST_CASE("CompositeSource_RestRetrieval_NamePublisher", "[RestSource][CompositeSource][RestRetrievalRegression]") +{ + auto behavior = GENERATE(CompositeSearchBehavior::Installed, CompositeSearchBehavior::AvailablePackages); + auto versionState = GENERATE("Known"sv, "Unknown"sv, "PartiallyCached"sv); + bool legacyName = GENERATE(false, true); + bool legacyPublisher = GENERATE(false, true); + CAPTURE(behavior, versionState, legacyName, legacyPublisher); + RestCorrelationTestSetup setup{ behavior }; + if (versionState != "Unknown") + { + setup.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + } + if (versionState == "PartiallyCached") + { + setup.SearchResponse[L"Data"][0][L"Versions"][1][L"PackageVersion"] = web::json::value::string(L"2.0.0"); + } + const std::string name = legacyName ? "Legacy App" : "New App"; + const std::string publisher = legacyPublisher ? "Legacy Publisher" : "New Publisher"; + auto installedManifest = MakeDefaultManifest("1.0.0"); + installedManifest.DefaultLocalization.Add(name); + installedManifest.DefaultLocalization.Add(publisher); + auto installed = TestCompositePackage::Make(installedManifest, TestCompositePackage::MetadataMap{}, + std::vector{}, setup.Installed); + setup.Installed->SearchFunction = [&](const SearchRequest& request) + { + SearchResult result; + if (request.Purpose == SearchPurpose::CorrelationToInstalled) + { + for (const auto& inclusion : request.Inclusions) + { + if (inclusion.Field == PackageMatchField::NormalizedNameAndPublisher && inclusion.Additional && + ICUCaseInsensitiveEquals(inclusion.Value, name) && + ICUCaseInsensitiveEquals(inclusion.Additional.value(), publisher)) + { + result.Matches.emplace_back(installed, inclusion); + break; + } + } + } + return result; + }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "tool"); + auto result = setup.Composite.Search(request); + REQUIRE(result.Failures.empty()); + bool shouldCorrelate = legacyName == legacyPublisher; + size_t expectedCount = behavior == CompositeSearchBehavior::Installed && !shouldCorrelate ? 0 : 1; + REQUIRE(result.Matches.size() == expectedCount); + if (expectedCount) + { + REQUIRE(static_cast(GetInstalledVersion(result.Matches[0].Package)) == shouldCorrelate); + REQUIRE(result.Matches[0].Package->GetAvailable().size() == 1); + } + REQUIRE(setup.ManifestRequests == (versionState == "PartiallyCached" ? size_t{ 2 } : size_t{ 1 })); +} + +TEST_CASE("CompositeSource_RestRetrieval_ManifestNamePublisherPairs", "[RestSource][CompositeSource][RestRetrievalRegression]") +{ + auto behavior = GENERATE(CompositeSearchBehavior::Installed, CompositeSearchBehavior::AvailablePackages); + bool cacheManifest = GENERATE(false, true); + size_t nameIndex = GENERATE(0, 1, 2); + size_t publisherIndex = GENERATE(0, 1, 2); + const std::vector names{ "New App", "Localized App", "Installed App" }; + const std::vector publishers{ "New Publisher", "Localized Publisher", "Installed Publisher" }; + const auto& name = names[nameIndex]; + const auto& publisher = publishers[publisherIndex]; + CAPTURE(behavior, cacheManifest, name, publisher); + RestCorrelationTestSetup setup{ behavior }; + setup.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + auto& manifestVersion = setup.ManifestResponse[L"Data"][L"Versions"][0]; + manifestVersion[L"Locales"] = web::json::value::parse(LR"([ + { "PackageLocale": "fr-FR", "PackageName": "Localized App", "Publisher": "Localized Publisher" } + ])"); + manifestVersion[L"Installers"][0][L"AppsAndFeaturesEntries"] = web::json::value::parse(LR"([ + { "DisplayName": "Installed App", "Publisher": "Installed Publisher" } + ])"); + auto installedManifest = MakeDefaultManifest("1.0.0"); + installedManifest.DefaultLocalization.Add(name); + installedManifest.DefaultLocalization.Add(publisher); + auto installed = TestCompositePackage::Make(installedManifest, TestCompositePackage::MetadataMap{}, + std::vector{}, setup.Installed); + setup.Installed->SearchFunction = [&](const SearchRequest& request) + { + SearchResult result; + if (request.Purpose == SearchPurpose::CorrelationToInstalled) + { + for (const auto& inclusion : request.Inclusions) + { + if (inclusion.Field == PackageMatchField::NormalizedNameAndPublisher && inclusion.Additional && + ICUCaseInsensitiveEquals(inclusion.Value, name) && + ICUCaseInsensitiveEquals(inclusion.Additional.value(), publisher)) + { + result.Matches.emplace_back(installed, inclusion); + break; + } + } + } + return result; + }; + SearchRequest request; + request.Filters.emplace_back(cacheManifest ? PackageMatchField::Moniker : PackageMatchField::Name, + MatchType::Exact, cacheManifest ? "tool"sv : "Legacy App"sv); + auto result = setup.Composite.Search(request); + REQUIRE(result.Failures.empty()); + bool shouldCorrelate = nameIndex == publisherIndex; + size_t expectedCount = behavior == CompositeSearchBehavior::Installed && !shouldCorrelate ? 0 : 1; + REQUIRE(result.Matches.size() == expectedCount); + if (expectedCount) + { + REQUIRE(static_cast(GetInstalledVersion(result.Matches[0].Package)) == shouldCorrelate); + REQUIRE(result.Matches[0].Package->GetAvailable().size() == 1); + } + REQUIRE(setup.ManifestRequests == 1); +} diff --git a/src/AppInstallerCLITests/MatchCriteriaResolver.cpp b/src/AppInstallerCLITests/MatchCriteriaResolver.cpp index 2854eb71b2..acdcca824f 100644 --- a/src/AppInstallerCLITests/MatchCriteriaResolver.cpp +++ b/src/AppInstallerCLITests/MatchCriteriaResolver.cpp @@ -20,6 +20,523 @@ void RequireMatchCriteria(const PackageMatchFilter& expected, const PackageMatch REQUIRE(expected.Value == actual.Value); } +TEST_CASE("MatchCriteriaResolver_MatchesRequest", "[MatchCriteriaResolver]") +{ + struct MatchCase + { + MatchType Type; + std::string_view Query; + std::string_view Value; + bool Expected; + }; + + const MatchCase cases[] = + { + { MatchType::Exact, "Foo.Bar", "Foo.Bar", true }, + { MatchType::Exact, "foo.bar", "Foo.Bar", false }, + { MatchType::Exact, "Foo", "Foo.Bar", false }, + { MatchType::CaseInsensitive, "foo.bar", "Foo.Bar", true }, + { MatchType::CaseInsensitive, "foo", "Foo.Bar", false }, + { MatchType::StartsWith, "foo", "Foo.Bar", true }, + { MatchType::StartsWith, "bar", "Foo.Bar", false }, + { MatchType::Substring, "BAR", "Foo.Bar", true }, + { MatchType::Substring, "Baz", "Foo.Bar", false }, + { MatchType::Exact, "caf\xC3\xA9", "cafe\xCC\x81", true }, + { MatchType::CaseInsensitive, "CAF\xC3\x89", "caf\xC3\xA9", true }, + { MatchType::Exact, "", "Foo.Bar", false }, + }; + + for (const auto& test : cases) + { + CAPTURE(ToString(test.Type), test.Query, test.Value); + auto result = MatchesRequest(RequestMatch{ test.Type, test.Query }, test.Value); + REQUIRE(result.has_value()); + REQUIRE(result.value() == test.Expected); + } +} + +TEST_CASE("MatchCriteriaResolver_MatchesRequest_Unsupported", "[MatchCriteriaResolver]") +{ + auto type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + REQUIRE_FALSE(MatchesRequest(RequestMatch{ type, "Foo" }, "Foo.Bar").has_value()); +} + +TEST_CASE("MatchCriteriaResolver_ManifestFields", "[MatchCriteriaResolver]") +{ + bool hasDefaultName = GENERATE(false, true); + CAPTURE(hasDefaultName); + Manifest::Manifest manifest; + manifest.Id = "Foo.Bar"; + manifest.Moniker = "FooBar"; + if (hasDefaultName) + { + manifest.DefaultLocalization.Add("Foo Bar"); + } + manifest.DefaultLocalization.Add({ "DefaultTag" }); + auto& localization = manifest.Localizations.emplace_back(); + localization.Add("Localized Name"); + localization.Add({ "Localized_Tag" }); + manifest.Localizations.emplace_back().Add(u8"Caf\u00E9"); + auto& installer = manifest.Installers.emplace_back(); + installer.Commands.emplace_back("Tool_Cmd"); + installer.PackageFamilyName = "Foo.Bar_123"; + installer.ProductCode = "Installer.Code"; + auto& entry = installer.AppsAndFeaturesEntries.emplace_back(); + entry.DisplayName = "Installed Name"; + entry.ProductCode = "ARP.Code"; + entry.UpgradeCode = "ARP.Upgrade"; + + struct MatchCase + { + PackageMatchField Field; + MatchType Type; + std::string_view Value; + std::optional Expected; + }; + + const MatchCase cases[] = + { + { PackageMatchField::Id, MatchType::Exact, "Foo.Bar", true }, + { PackageMatchField::Id, MatchType::Exact, "foo.bar", false }, + { PackageMatchField::Name, MatchType::Exact, "Foo Bar", hasDefaultName }, + { PackageMatchField::Name, MatchType::Exact, "foo bar", false }, + { PackageMatchField::Name, MatchType::CaseInsensitive, "foo bar", hasDefaultName }, + { PackageMatchField::Name, MatchType::Exact, "Localized Name", true }, + { PackageMatchField::Name, MatchType::Exact, "localized name", false }, + { PackageMatchField::Name, MatchType::Exact, "Installed Name", true }, + { PackageMatchField::Name, MatchType::Exact, "installed name", false }, + { PackageMatchField::Name, MatchType::StartsWith, "localized", true }, + { PackageMatchField::Name, MatchType::Substring, "NAME", true }, + { PackageMatchField::Name, MatchType::Exact, u8"Cafe\u0301", true }, + { PackageMatchField::Moniker, MatchType::Exact, "FooBar", true }, + { PackageMatchField::Moniker, MatchType::Exact, "foobar", false }, + { PackageMatchField::Moniker, MatchType::CaseInsensitive, "foobar", true }, + { PackageMatchField::Tag, MatchType::Exact, "DefaultTag", true }, + { PackageMatchField::Tag, MatchType::Exact, "Localized_Tag", true }, + { PackageMatchField::Tag, MatchType::Exact, "localized_tag", false }, + { PackageMatchField::Command, MatchType::Exact, "Tool_Cmd", true }, + { PackageMatchField::Command, MatchType::Exact, "tool_cmd", false }, + { PackageMatchField::PackageFamilyName, MatchType::Exact, "FOO.BAR_123", true }, + { PackageMatchField::ProductCode, MatchType::Exact, "INSTALLER.CODE", true }, + { PackageMatchField::ProductCode, MatchType::Exact, "ARP.CODE", true }, + { PackageMatchField::UpgradeCode, MatchType::Exact, "ARP.UPGRADE", true }, + { PackageMatchField::Name, MatchType::Fuzzy, "Foo", std::nullopt }, + { PackageMatchField::Name, MatchType::FuzzySubstring, "Foo", std::nullopt }, + { PackageMatchField::Name, MatchType::Wildcard, "Foo*", std::nullopt }, + { PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, "Foo Bar", std::nullopt }, + { PackageMatchField::Market, MatchType::Exact, "US", std::nullopt }, + { PackageMatchField::Unknown, MatchType::Exact, "Foo Bar", std::nullopt }, + }; + + for (const auto& test : cases) + { + CAPTURE(ToString(test.Field), ToString(test.Type), test.Value); + REQUIRE(MatchesRequest(PackageMatchFilter{ test.Field, test.Type, test.Value }, manifest) == test.Expected); + } + for (auto field : { PackageMatchField::Id, PackageMatchField::Name, PackageMatchField::Moniker, + PackageMatchField::Tag, PackageMatchField::Command, PackageMatchField::PackageFamilyName, + PackageMatchField::ProductCode, PackageMatchField::UpgradeCode }) + { + CAPTURE(ToString(field)); + REQUIRE(MatchesRequest(PackageMatchFilter{ field, MatchType::Exact, "Missing.Value" }, manifest) == std::optional{ false }); + } +} + +TEST_CASE("MatchCriteriaResolver_ManifestNormalizedNameAndPublisher_Unknown", "[MatchCriteriaResolver]") +{ + Manifest::Manifest manifest; + manifest.DefaultLocalization.Add("Foo Bar"); + manifest.DefaultLocalization.Add("Foo"); + auto name = GENERATE("Foo Bar", "Other Name"); + auto publisher = GENERATE("Foo", "Other Publisher"); + CAPTURE(name, publisher); + + PackageMatchFilter request{ PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, name, publisher }; + REQUIRE_FALSE(MatchesRequest(request, manifest).has_value()); +} + +TEST_CASE("MatchCriteriaResolver_ManifestEmptyFields", "[MatchCriteriaResolver]") +{ + Manifest::Manifest manifest; + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::Moniker, PackageMatchField::Tag, PackageMatchField::Command, + PackageMatchField::PackageFamilyName, PackageMatchField::ProductCode, PackageMatchField::UpgradeCode); + auto type = GENERATE(MatchType::Exact, MatchType::CaseInsensitive, MatchType::StartsWith, MatchType::Substring); + CAPTURE(ToString(field), ToString(type)); + PackageMatchFilter request{ field, type, "" }; + REQUIRE(MatchesRequest(request, manifest) == std::optional{ false }); + request.Type = MatchType::Wildcard; + REQUIRE_FALSE(MatchesRequest(request, manifest).has_value()); +} + +TEST_CASE("MatchCriteriaResolver_SearchRequest", "[MatchCriteriaResolver]") +{ + const PackageMatchFilter idMatch{ PackageMatchField::Id, MatchType::CaseInsensitive, "microsoft.powertoys" }; + const PackageMatchFilter nameMatch{ PackageMatchField::Name, MatchType::Exact, "Microsoft PowerToys" }; + const PackageMatchFilter idMismatch{ PackageMatchField::Id, MatchType::Exact, "Other.Package" }; + const PackageMatchFilter unknown{ PackageMatchField::Moniker, MatchType::CaseInsensitive, "powertoys" }; + const PackageMatchFilter unsupported{ PackageMatchField::Id, MatchType::Fuzzy, "powertoys" }; + + struct MatchCase + { + std::string_view Name; + std::vector Filters; + std::vector Inclusions; + bool HasQuery; + std::optional Expected; + }; + + const MatchCase cases[] = + { + { "Empty request", {}, {}, false, true }, + { "All filters match", { idMatch, nameMatch }, {}, false, true }, + { "Every filter must match", { idMatch, idMismatch }, {}, false, false }, + { "Unknown filter", { idMatch, unknown }, {}, false, std::nullopt }, + { "Failed filter after unknown", { unknown, idMismatch }, {}, false, false }, + { "Any inclusion may match", {}, { idMismatch, nameMatch }, false, true }, + { "Failed inclusions", {}, { idMismatch }, false, false }, + { "Unknown inclusion may match", {}, { idMismatch, unknown }, false, std::nullopt }, + { "Match after unknown inclusion", {}, { unknown, idMatch }, false, true }, + { "Inclusion cannot override failed filter", { idMismatch }, { nameMatch }, false, false }, + { "Filters cannot override failed inclusions", { idMatch }, { idMismatch }, false, false }, + { "Matching inclusion with unknown filter", { unknown }, { idMatch }, false, std::nullopt }, + { "Failed inclusions with unknown filter", { unknown }, { idMismatch }, false, false }, + { "Failed filter with unknown inclusion", { idMismatch }, { unknown }, false, false }, + { "Unknown filter and inclusion", { unknown }, { unknown }, false, std::nullopt }, + { "Unsupported filter match type", { unsupported }, {}, false, std::nullopt }, + { "Unsupported inclusion match type", {}, { unsupported }, false, std::nullopt }, + { "Source-defined query", {}, {}, true, std::nullopt }, + { "Query may select despite failed inclusions", { idMatch }, { idMismatch }, true, std::nullopt }, + { "Query cannot override failed filter", { idMismatch }, { idMatch }, true, false }, + { "Matching inclusion alongside query", {}, { idMatch }, true, true }, + }; + + auto matchesField = [](const PackageMatchFilter& filter) -> std::optional + { + switch (filter.Field) + { + case PackageMatchField::Id: + return MatchesRequest(filter, "Microsoft.PowerToys"); + case PackageMatchField::Name: + return MatchesRequest(filter, "Microsoft PowerToys"); + default: + return std::nullopt; + } + }; + + for (const auto& test : cases) + { + CAPTURE(test.Name); + SearchRequest request; + request.Filters = test.Filters; + request.Inclusions = test.Inclusions; + if (test.HasQuery) + { + request.Query.emplace(MatchType::CaseInsensitive, "powertoys"); + } + REQUIRE(MatchesRequest(request, matchesField) == test.Expected); + } +} + +TEST_CASE("MatchCriteriaResolver_SearchRequestTruthTable", "[MatchCriteriaResolver]") +{ + auto firstFilter = GENERATE(std::optional{}, std::optional{ false }, std::optional{ true }); + auto secondFilter = GENERATE(std::optional{}, std::optional{ false }, std::optional{ true }); + auto firstInclusion = GENERATE(std::optional{}, std::optional{ false }, std::optional{ true }); + auto secondInclusion = GENERATE(std::optional{}, std::optional{ false }, std::optional{ true }); + bool hasQuery = GENERATE(false, true); + CAPTURE(firstFilter, secondFilter, firstInclusion, secondInclusion, hasQuery); + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Name"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Id"); + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "Moniker"); + request.Inclusions.emplace_back(PackageMatchField::Tag, MatchType::Exact, "Tag"); + if (hasQuery) + { + request.Query.emplace(MatchType::Substring, "Query"); + } + std::vector evaluatedFields; + auto matchesField = [&](const PackageMatchFilter& field) -> std::optional + { + evaluatedFields.emplace_back(field.Field); + switch (field.Field) + { + case PackageMatchField::Name: return firstFilter; + case PackageMatchField::Id: return secondFilter; + case PackageMatchField::Moniker: return firstInclusion; + case PackageMatchField::Tag: return secondInclusion; + default: THROW_HR(E_UNEXPECTED); + } + }; + + std::optional expected; + if (firstFilter == false || secondFilter == false || + (!hasQuery && firstInclusion == false && secondInclusion == false)) + { + expected = false; + } + else if (firstFilter == true && secondFilter == true && (firstInclusion == true || secondInclusion == true)) + { + expected = true; + } + REQUIRE(MatchesRequest(request, matchesField) == expected); + + std::vector expectedEvaluatedFields{ PackageMatchField::Name }; + if (firstFilter != false) + { + expectedEvaluatedFields.emplace_back(PackageMatchField::Id); + if (secondFilter != false) + { + expectedEvaluatedFields.emplace_back(PackageMatchField::Moniker); + if (firstInclusion != true) + { + expectedEvaluatedFields.emplace_back(PackageMatchField::Tag); + } + } + } + REQUIRE(evaluatedFields == expectedEvaluatedFields); +} + +TEST_CASE("MatchCriteriaResolver_ResolveUnknownCriteria", "[MatchCriteriaResolver]") +{ + Manifest::Manifest manifest; + manifest.Id = "Foo.Bar"; + manifest.DefaultLocalization.Add("Foo Bar"); + manifest.Moniker = "foobar"; + + const PackageMatchFilter idMatch{ PackageMatchField::Id, MatchType::Exact, "Foo.Bar" }; + const PackageMatchFilter idMismatch{ PackageMatchField::Id, MatchType::Exact, "Other.Package" }; + const PackageMatchFilter nameMatch{ PackageMatchField::Name, MatchType::Exact, "Foo Bar" }; + const PackageMatchFilter monikerMismatch{ PackageMatchField::Moniker, MatchType::Exact, "other" }; + const PackageMatchFilter unknown{ PackageMatchField::Market, MatchType::Exact, "US" }; + + struct MatchCase + { + std::string_view Name; + std::vector Filters; + std::vector Inclusions; + bool HasQuery; + std::optional Expected; + std::vector ResolvedFields; + }; + + const MatchCase cases[] = + { + { "Empty request", {}, {}, false, true, {} }, + { "Known matching filter", { idMatch }, {}, false, true, {} }, + { "Known failed filter after unknown", { nameMatch, idMismatch }, {}, false, false, {} }, + { "Known inclusion after unknown", {}, { nameMatch, idMatch }, false, true, {} }, + { "Failed inclusions with unknown filter", { nameMatch }, { idMismatch }, false, false, {} }, + { "Failed filter with unknown inclusion", { idMismatch }, { nameMatch }, false, false, {} }, + { "Resolve matching filter", { idMatch, nameMatch }, {}, false, true, { PackageMatchField::Name } }, + { "Resolve failing filter", { monikerMismatch }, {}, false, false, { PackageMatchField::Moniker } }, + { "Filter remains unknown", { unknown }, {}, false, std::nullopt, { PackageMatchField::Market } }, + { "Resolve matching inclusion", {}, { idMismatch, nameMatch }, false, true, { PackageMatchField::Name } }, + { "Resolve failing inclusion", {}, { monikerMismatch }, false, false, { PackageMatchField::Moniker } }, + { "Inclusion remains unknown", {}, { unknown }, false, std::nullopt, { PackageMatchField::Market } }, + { "Resolved mismatch stops later filters", { monikerMismatch, nameMatch }, {}, false, false, { PackageMatchField::Moniker } }, + { "Resolved mismatch avoids inclusion lookup", { monikerMismatch }, { nameMatch }, false, false, { PackageMatchField::Moniker } }, + { "Resolved match stops later inclusions", {}, { nameMatch, monikerMismatch }, false, true, { PackageMatchField::Name } }, + { "Resolve next inclusion after mismatch", {}, { monikerMismatch, nameMatch }, false, true, { PackageMatchField::Moniker, PackageMatchField::Name } }, + { "Unknown filter cannot override failed inclusions", { unknown }, { monikerMismatch }, false, false, { PackageMatchField::Market, PackageMatchField::Moniker } }, + { "Matching inclusion cannot prove unknown filter", { unknown }, { nameMatch }, false, std::nullopt, { PackageMatchField::Market, PackageMatchField::Name } }, + { "Resolved inclusion stops lookups despite unknown filter", { unknown }, { nameMatch, monikerMismatch }, false, std::nullopt, { PackageMatchField::Market, PackageMatchField::Name } }, + { "Matching filter cannot prove unknown inclusion", { nameMatch }, { unknown }, false, std::nullopt, { PackageMatchField::Name, PackageMatchField::Market } }, + { "Source-defined query", {}, {}, true, std::nullopt, {} }, + { "Query makes inclusion lookup unnecessary", {}, { nameMatch }, true, std::nullopt, {} }, + { "Query still requires filter resolution", { nameMatch }, { monikerMismatch }, true, std::nullopt, { PackageMatchField::Name } }, + { "Query cannot override resolved filter failure", { monikerMismatch }, { nameMatch }, true, false, { PackageMatchField::Moniker } }, + { "Known inclusion alongside query", {}, { nameMatch, idMatch }, true, true, {} }, + { "Resolve only filter when selection is known", { unknown }, { nameMatch, idMatch }, false, std::nullopt, { PackageMatchField::Market } }, + }; + + auto matchesField = [&](const PackageMatchFilter& field) -> std::optional + { + return field.Field == PackageMatchField::Id ? MatchesRequest(field, manifest.Id) : std::nullopt; + }; + for (const auto& test : cases) + { + CAPTURE(test.Name); + SearchRequest request; + request.Filters = test.Filters; + request.Inclusions = test.Inclusions; + if (test.HasQuery) + { + request.Query.emplace(MatchType::Substring, "Source-defined query"); + } + + std::vector resolvedFields; + auto resolveField = [&](const PackageMatchFilter& field) + { + resolvedFields.emplace_back(field.Field); + return MatchesRequest(field, manifest); + }; + REQUIRE(MatchesRequest(request, matchesField, resolveField) == test.Expected); + REQUIRE(resolvedFields == test.ResolvedFields); + } +} + +TEST_CASE("MatchCriteriaResolver_ResolutionAttemptsUnknownsOnce", "[MatchCriteriaResolver]") +{ + bool hasQuery = GENERATE(false, true); + CAPTURE(hasQuery); + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Name"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Id"); + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "Moniker"); + request.Inclusions.emplace_back(PackageMatchField::Tag, MatchType::Exact, "Tag"); + if (hasQuery) + { + request.Query.emplace(MatchType::Substring, "Query"); + } + size_t idEvaluations = 0; + auto matchesField = [&](const PackageMatchFilter& field) -> std::optional + { + if (field.Field == PackageMatchField::Id) + { + ++idEvaluations; + return true; + } + return std::nullopt; + }; + std::vector resolvedFields; + auto resolveField = [&](const PackageMatchFilter& field) -> std::optional + { + resolvedFields.emplace_back(field.Field); + return std::nullopt; + }; + std::vector expectedResolvedFields{ PackageMatchField::Name }; + if (!hasQuery) + { + expectedResolvedFields.insert(expectedResolvedFields.end(), { PackageMatchField::Moniker, PackageMatchField::Tag }); + } + + REQUIRE_FALSE(MatchesRequest(request, matchesField, resolveField).has_value()); + REQUIRE(resolvedFields == expectedResolvedFields); + REQUIRE(idEvaluations == 1); +} + +TEST_CASE("MatchCriteriaResolver_ResolutionReusesAvailableMetadata", "[MatchCriteriaResolver]") +{ + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Foo Bar"); + request.Filters.emplace_back(PackageMatchField::Tag, MatchType::Exact, "utility"); + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "foobar"); + + std::optional manifest; + auto matchesField = [&](const PackageMatchFilter& field) -> std::optional + { + return manifest ? MatchesRequest(field, manifest.value()) : std::nullopt; + }; + std::vector resolvedFields; + auto resolveField = [&](const PackageMatchFilter& field) + { + resolvedFields.emplace_back(field.Field); + auto& data = manifest.emplace(); + data.Id = "Foo.Bar"; + data.DefaultLocalization.Add("Foo Bar"); + data.DefaultLocalization.Add({ "utility" }); + data.Moniker = "foobar"; + return MatchesRequest(field, data); + }; + + REQUIRE(MatchesRequest(request, matchesField, resolveField) == std::optional{ true }); + REQUIRE(resolvedFields == std::vector{ PackageMatchField::Name }); +} + +TEST_CASE("MatchCriteriaResolver_ResolutionRefreshesEarlierCriteria", "[MatchCriteriaResolver]") +{ + const PackageMatchFilter nameMatch{ PackageMatchField::Name, MatchType::Exact, "Foo Bar" }; + const PackageMatchFilter nameMismatch{ PackageMatchField::Name, MatchType::Exact, "Other" }; + const PackageMatchFilter monikerMatch{ PackageMatchField::Moniker, MatchType::Exact, "foobar" }; + const PackageMatchFilter monikerMismatch{ PackageMatchField::Moniker, MatchType::Exact, "other" }; + const PackageMatchFilter tagMatch{ PackageMatchField::Tag, MatchType::Exact, "utility" }; + SearchRequest request; + request.Filters = { nameMismatch }; + request.Inclusions = { monikerMatch }; + std::optional expected = false; + std::vector expectedResolvedFields{ PackageMatchField::Name, PackageMatchField::Moniker }; + bool tagKnownOnlyToResolver = false; + + SECTION("Inclusion resolution reveals a failed filter") {} + SECTION("Inclusion resolution confirms an earlier filter") + { + request.Filters = { nameMatch }; + expected = true; + } + SECTION("Later filter resolution reveals an earlier failure") + { + request.Filters = { nameMismatch, tagMatch }; + request.Filters.emplace_back(PackageMatchField::Market, MatchType::Exact, "US"); + expectedResolvedFields = { PackageMatchField::Name, PackageMatchField::Tag }; + } + SECTION("Later inclusion resolution reveals an earlier match") + { + request.Filters.clear(); + request.Inclusions = { nameMatch, monikerMismatch }; + expected = true; + } + SECTION("Later inclusion resolution rules out all alternatives") + { + request.Filters.clear(); + request.Inclusions = { nameMismatch, monikerMismatch }; + } + SECTION("Refreshing unknowns preserves definitive resolver answers") + { + request.Filters = { tagMatch, nameMatch }; + tagKnownOnlyToResolver = true; + expected = true; + expectedResolvedFields = { PackageMatchField::Tag, PackageMatchField::Name, PackageMatchField::Moniker }; + } + + std::optional manifest; + auto matchesField = [&](const PackageMatchFilter& field) -> std::optional + { + if (tagKnownOnlyToResolver && field.Field == PackageMatchField::Tag) + { + return std::nullopt; + } + return manifest ? MatchesRequest(field, manifest.value()) : std::nullopt; + }; + std::vector resolvedFields; + auto resolveField = [&](const PackageMatchFilter& field) -> std::optional + { + resolvedFields.emplace_back(field.Field); + if (field.Field == PackageMatchField::Name) + { + return std::nullopt; + } + if (tagKnownOnlyToResolver && field.Field == PackageMatchField::Tag) + { + return true; + } + auto& data = manifest.emplace(); + data.Id = "Foo.Bar"; + data.DefaultLocalization.Add("Foo Bar"); + data.DefaultLocalization.Add({ "utility" }); + data.Moniker = "foobar"; + return MatchesRequest(field, data); + }; + + REQUIRE(MatchesRequest(request, matchesField, resolveField) == expected); + REQUIRE(resolvedFields == expectedResolvedFields); +} + +TEST_CASE("MatchCriteriaResolver_ResolutionFailure", "[MatchCriteriaResolver]") +{ + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Foo Bar"); + auto matchesField = [](const PackageMatchFilter&) -> std::optional + { + return std::nullopt; + }; + auto resolveField = [](const PackageMatchFilter&) -> std::optional + { + THROW_HR(E_ACCESSDENIED); + }; + + REQUIRE_THROWS_HR(MatchesRequest(request, matchesField, resolveField), E_ACCESSDENIED); +} + TEST_CASE("MatchCriteriaResolver_MatchType", "[MatchCriteriaResolver]") { Manifest::Manifest manifest; diff --git a/src/AppInstallerCLITests/RestInterface_1_0.cpp b/src/AppInstallerCLITests/RestInterface_1_0.cpp index 374f61e568..6a7fe856a3 100644 --- a/src/AppInstallerCLITests/RestInterface_1_0.cpp +++ b/src/AppInstallerCLITests/RestInterface_1_0.cpp @@ -3,10 +3,13 @@ #include "pch.h" #include "TestCommon.h" #include "TestRestRequestHandler.h" +#include #include +#include #include #include #include +#include #include #include @@ -23,6 +26,57 @@ namespace { const std::string TestRestUriString = "http://restsource.com/api"; + utility::string_t GetSearchResponse_PackageIds( + std::initializer_list identifiers, const utility::string_t& continuationToken = {}) + { + web::json::value response; + response[L"Data"] = web::json::value::array(); + size_t index = 0; + for (const auto& identifier : identifiers) + { + web::json::value package; + package[L"PackageIdentifier"] = web::json::value::string(identifier); + package[L"PackageName"] = web::json::value::string(L"Microsoft Teams"); + package[L"Publisher"] = web::json::value::string(L"Microsoft"); + package[L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + response[L"Data"][index++] = std::move(package); + } + + if (!continuationToken.empty()) + { + response[L"ContinuationToken"] = web::json::value::string(continuationToken); + } + + return response.serialize(); + } + + struct CachedMetadataInterface : Interface + { + using Interface::Interface; + std::vector Versions; + + protected: + IRestClient::SearchResult GetSearchResult(const web::json::value& response) const override + { + auto result = Interface::GetSearchResult(response); + result.Matches.at(0).Versions = Versions; + return result; + } + }; + + struct QueryValidationTrackingInterface : V1_1::Interface + { + using V1_1::Interface::Interface; + mutable size_t QueryValidations = 0; + + protected: + std::map GetValidatedQueryParams(const std::map& params) const override + { + ++QueryValidations; + return V1_1::Interface::GetValidatedQueryParams(params); + } + }; + utility::string_t GetGoodManifest_RequiredFields() { return _XPLATSTR( @@ -101,6 +155,54 @@ namespace })delimiter"); } + struct SearchAndManifestResponses + { + web::json::value SearchResponse = web::json::value::parse(GetSearchResponse_PackageIds({ L"Foo.Bar" })); + web::json::value ManifestResponse = web::json::value::parse(GetGoodManifest_RequiredFields()); + web::http::status_code ManifestStatus = web::http::status_codes::OK; + size_t SearchRequests = 0; + size_t ManifestRequests = 0; + web::http::http_request LastSearchRequest; + web::http::http_request LastManifestRequest; + + SearchAndManifestResponses() + { + ManifestResponse[L"Data"][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + } + + void SetManifestNotFound() + { + ManifestStatus = web::http::status_codes::NotFound; + ManifestResponse = web::json::value::parse(LR"({"code":"DataNotFound","message":"Not found"})"); + } + + std::shared_ptr GetHandler() + { + return std::make_shared( + [this](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + ++SearchRequests; + LastSearchRequest = request; + response.set_status_code(web::http::status_codes::OK); + response.set_body(SearchResponse); + } + else if (request.method() == web::http::methods::GET) + { + ++ManifestRequests; + LastManifestRequest = request; + response.set_status_code(ManifestStatus); + response.set_body(ManifestResponse); + } + return pplx::task_from_result(response); + }); + } + }; + struct GoodManifest_AllFields { utility::string_t GetSampleManifest_AllFields() @@ -398,39 +500,1421 @@ TEST_CASE("Search_GoodResponse_404AsEmpty", "[RestSource][Interface_1_0]") REQUIRE(searchResponse.Matches.size() == 0); } -TEST_CASE("Search_ContinuationToken", "[RestSource][Interface_1_0]") +TEST_CASE("Search_ExplicitIdFilters", "[RestSource][Interface_1_0]") { - utility::string_t sample = _XPLATSTR( - R"delimiter({ - "Data" : [ - { - "PackageIdentifier": "git.package", - "PackageName": "package", - "Publisher": "git", - "Versions": [ - { "PackageVersion": "1.0.0" }] - }, + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"XP8BT8DW290MPQ", L"Microsoft.Teams", L"Microsoft.Teams.Preview" })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::CaseInsensitive, "Microsoft Teams"); + std::vector expected{ "Microsoft.Teams" }; + + SECTION("Exact") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Microsoft.Teams"); + } + SECTION("Exact case mismatch") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "microsoft.teams"); + expected.clear(); + } + SECTION("Case insensitive") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "microsoft.teams"); + } + SECTION("Starts with") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "microsoft.teams"); + expected.emplace_back("Microsoft.Teams.Preview"); + } + SECTION("Substring") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Substring, "teams"); + expected.emplace_back("Microsoft.Teams.Preview"); + } + SECTION("All ID filters must match") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Microsoft.Teams"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Microsoft.Teams.Preview"); + expected.clear(); + } + SECTION("Query and inclusions cannot override a failed filter") + { + request.Query.emplace(MatchType::Substring, "Teams"); + request.Inclusions.emplace_back(PackageMatchField::Name, MatchType::Exact, "Microsoft Teams"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expected.clear(); + } + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == expected.size()); + REQUIRE_FALSE(result.Truncated); + for (size_t i = 0; i < expected.size(); ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == expected[i]); + } +} + +TEST_CASE("Search_ExplicitIdFilters_UnicodePrefix", "[RestSource][Interface_1_0]") +{ + std::wstring id = GENERATE(L"Vendor.\u1E9EApp", L"Vendor.\u00DFApp", L"Vendor.SSApp"); + std::string prefix = GENERATE(u8"vendor.\u00DF", u8"vendor.\u1E9E", "vendor.ss"); + CAPTURE(id, prefix); + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"Vendor.Other", id })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, prefix); + request.MaximumResults = 1; + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == ConvertToUTF8(id)); + REQUIRE_FALSE(result.Truncated); +} + +TEST_CASE("Search_IdInclusions", "[RestSource][Interface_1_0]") +{ + SearchAndManifestResponses responses; + responses.SearchResponse = web::json::value::parse(GetSearchResponse_PackageIds({ L"Foo.Bar", L"Foo.Baz", L"Other.Package" })); + responses.SetManifestNotFound(); + HttpClientHelper helper{ responses.GetHandler() }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + const std::vector allIds{ "Foo.Bar", "Foo.Baz", "Other.Package" }; + std::vector expected{ "Foo.Bar" }; + size_t expectedManifestRequests = 0; + + SECTION("Matching inclusion") {} + SECTION("Any inclusion may match") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expected.emplace_back("Other.Package"); + } + SECTION("No matching inclusion") + { + request.Inclusions[0].Value = "Missing.Package"; + expected.clear(); + } + SECTION("Filters narrow matching inclusions") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Foo."); + } + SECTION("Matching filters cannot override failed inclusions") + { + request.Inclusions[0].Value = "Missing.Package"; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + expected.clear(); + } + SECTION("Unknown inclusion preserves candidates") + { + request.Inclusions.emplace_back(PackageMatchField::Name, MatchType::Exact, "Localized name"); + expected = allIds; + expectedManifestRequests = 2; + } + SECTION("Unknown filter does not disable inclusion matching") + { + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Localized name"); + expectedManifestRequests = 1; + } + SECTION("A query may select independently of inclusions") + { + request.Query.emplace(MatchType::Substring, "Source-defined query"); + expected = allIds; + } + SECTION("Unsupported match types remain unknown") + { + request.Inclusions[0].Type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + expected = allIds; + } + + auto result = v1.Search(request); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == expectedManifestRequests); + REQUIRE(result.Matches.size() == expected.size()); + REQUIRE_FALSE(result.Truncated); + for (size_t i = 0; i < expected.size(); ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == expected[i]); + } +} + +TEST_CASE("Search_CachedManifestMetadata", "[RestSource][Interface_1_0]") +{ + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::Moniker, PackageMatchField::Tag, + PackageMatchField::Command, PackageMatchField::PackageFamilyName, PackageMatchField::ProductCode, + PackageMatchField::UpgradeCode); + bool useInclusions = GENERATE(false, true); + CAPTURE(ToString(field), useInclusions); + SearchAndManifestResponses responses; + responses.SetManifestNotFound(); + HttpClientHelper helper{ responses.GetHandler() }; + CachedMetadataInterface v1{ TestRestUriString, helper }; + auto createManifest = [](std::string_view version, const NormalizedString& value) + { + Manifest manifest; + manifest.Id = "Foo.Bar"; + manifest.Version = version; + manifest.Moniker = value; + manifest.DefaultLocalization.Add(value); + manifest.DefaultLocalization.Add({ value }); + auto& installer = manifest.Installers.emplace_back(); + installer.Commands.emplace_back(value); + installer.PackageFamilyName = value; + installer.ProductCode = value; + installer.AppsAndFeaturesEntries.emplace_back().UpgradeCode = value; + return manifest; + }; + v1.Versions.emplace_back(VersionAndChannel{ Version{ "1.0.0" }, Channel{} }, createManifest("1.0.0", "Other")); + v1.Versions.emplace_back(VersionAndChannel{ Version{ "2.0.0" }, Channel{} }, createManifest("2.0.0", "Other")); + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + auto& criteria = useInclusions ? request.Inclusions : request.Filters; + criteria.emplace_back(field, MatchType::Exact, "Match"); + size_t expectedCount = 0; + size_t expectedManifestRequests = 0; + + SECTION("All cached versions mismatch") {} + SECTION("An earlier cached version matches") + { + v1.Versions[0].Manifest = createManifest("1.0.0", "Match"); + expectedCount = 1; + } + SECTION("A later cached version matches") + { + v1.Versions[1].Manifest = createManifest("2.0.0", "Match"); + expectedCount = 1; + } + SECTION("An uncached version preserves unknown") + { + v1.Versions[0].Manifest.reset(); + expectedCount = 1; + expectedManifestRequests = 1; + } + SECTION("A match alongside an uncached version is retained") + { + v1.Versions[0].Manifest.reset(); + v1.Versions[1].Manifest = createManifest("2.0.0", "Match"); + expectedCount = 1; + } + SECTION("Missing manifests remain unknown") + { + v1.Versions[0].Manifest.reset(); + v1.Versions[1].Manifest.reset(); + expectedCount = 1; + expectedManifestRequests = 1; + } + SECTION("Unsupported comparisons remain unknown") + { + criteria.back().Type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + expectedCount = 1; + } + SECTION("A query can select independently but cannot bypass filters") + { + request.Query.emplace(MatchType::Substring, "Source-defined query"); + expectedCount = useInclusions ? size_t{ 1 } : size_t{ 0 }; + } + SECTION("An ID mismatch still rejects matching metadata") + { + request.Filters[0].Value = "Other.Package"; + v1.Versions[1].Manifest = createManifest("2.0.0", "Match"); + } + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == expectedManifestRequests); + REQUIRE_FALSE(result.Truncated); +} + +TEST_CASE("Search_ReturnedMetadata", "[RestSource][Interface_1_0]") +{ + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::PackageFamilyName, + PackageMatchField::ProductCode, PackageMatchField::UpgradeCode); + auto type = GENERATE(MatchType::Exact, MatchType::CaseInsensitive); + CAPTURE(ToString(field), ToString(type)); + SearchAndManifestResponses responses; + responses.SetManifestNotFound(); + HttpClientHelper helper{ responses.GetHandler() }; + CachedMetadataInterface v1{ TestRestUriString, helper }; + Manifest manifest; + manifest.Id = "Foo.Bar"; + manifest.Version = "1.0.0"; + manifest.DefaultLocalization.Add("Other"); + v1.Versions.emplace_back(VersionAndChannel{ manifest.Version, manifest.Channel }, manifest); + auto setReturnedValue = [&](std::string_view value) + { + switch (field) + { + case PackageMatchField::Name: + responses.SearchResponse[L"Data"][0][L"PackageName"] = web::json::value::string(ConvertToUTF16(value)); + break; + case PackageMatchField::PackageFamilyName: + v1.Versions[0].PackageFamilyNames = { std::string{ value } }; + break; + case PackageMatchField::ProductCode: + v1.Versions[0].ProductCodes = { std::string{ value } }; + break; + case PackageMatchField::UpgradeCode: + v1.Versions[0].UpgradeCodes = { std::string{ value } }; + break; + } + }; + setReturnedValue("Match"); + SearchRequest request; + request.Filters.emplace_back(field, type, "Match"); + size_t expectedCount = 1; + size_t expectedManifestRequests = 0; + + SECTION("Returned metadata confirms a match despite cached metadata") {} + SECTION("Case differences follow field-specific rules") + { + request.Filters[0].Value = "match"; + if (field == PackageMatchField::Name && type == MatchType::Exact) + { + expectedCount = 0; + } + } + SECTION("Nonmatching response metadata is inconclusive without a manifest") + { + setReturnedValue("Other"); + v1.Versions[0].Manifest.reset(); + expectedManifestRequests = 1; + } + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == expectedManifestRequests); + REQUIRE_FALSE(result.Truncated); +} + +TEST_CASE("Search_ManifestResolution_Fields", "[RestSource][Interface_1_4]") +{ + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::Moniker, PackageMatchField::Tag, + PackageMatchField::Command, PackageMatchField::PackageFamilyName, PackageMatchField::ProductCode, + PackageMatchField::UpgradeCode); + bool useInclusions = GENERATE(false, true); + CAPTURE(ToString(field), useInclusions); + SearchAndManifestResponses responses; + auto& version = responses.ManifestResponse[L"Data"][L"Versions"][0]; + std::string value = "Wanted"; + switch (field) + { + case PackageMatchField::Name: + version[L"Locales"][0][L"PackageLocale"] = web::json::value::string(L"fr-FR"); + version[L"Locales"][0][L"PackageName"] = web::json::value::string(L"Wanted"); + break; + case PackageMatchField::Moniker: + version[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"Wanted"); + break; + case PackageMatchField::Tag: + version[L"DefaultLocale"][L"Tags"][0] = web::json::value::string(L"Wanted"); + break; + case PackageMatchField::Command: + version[L"Installers"][0][L"Commands"][0] = web::json::value::string(L"Wanted"); + break; + case PackageMatchField::PackageFamilyName: + value = "Test.Package_8wekyb3d8bbwe"; + version[L"Installers"][0][L"InstallerType"] = web::json::value::string(L"msix"); + version[L"Installers"][0][L"PackageFamilyName"] = web::json::value::string(ConvertToUTF16(value)); + break; + case PackageMatchField::ProductCode: + value = "{A0000000-0000-0000-0000-000000000001}"; + version[L"Installers"][0][L"ProductCode"] = web::json::value::string(ConvertToUTF16(value)); + break; + case PackageMatchField::UpgradeCode: + value = "{A0000000-0000-0000-0000-000000000002}"; + version[L"Installers"][0][L"AppsAndFeaturesEntries"][0][L"UpgradeCode"] = web::json::value::string(ConvertToUTF16(value)); + break; + } + size_t expectedCount = 1; + bool hasManifest = true; + SECTION("Retrieved metadata matches") {} + SECTION("Retrieved metadata rejects the candidate") + { + value = "Missing"; + expectedCount = 0; + } + SECTION("Missing manifests preserve unknown") + { + responses.SetManifestNotFound(); + hasManifest = false; + } + + HttpClientHelper helper{ responses.GetHandler() }; + V1_4::Interface rest{ TestRestUriString, helper, {} }; + SearchRequest request; + auto& criteria = useInclusions ? request.Inclusions : request.Filters; + criteria.emplace_back(field, MatchType::Exact, value); + auto result = rest.Search(request); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); + REQUIRE(responses.LastManifestRequest.absolute_uri().path() == L"/api/packageManifests/Foo.Bar"); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE_FALSE(result.Truncated); + if (expectedCount) + { + REQUIRE(result.Matches[0].PackageInformation.PackageName == + ConvertToUTF8(responses.SearchResponse[L"Data"][0][L"PackageName"].as_string())); + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value() == hasManifest); + REQUIRE(result.Matches[0].Versions[0].VersionAndChannel.GetVersion().ToString() == "1.0.0"); + } +} + +TEST_CASE("Search_ManifestResolution_PositionalQuery", "[RestSource][Interface_1_0]") +{ + std::string query = GENERATE("browser", "."); + bool matches = GENERATE(false, true); + bool unknownVersion = GENERATE(false, true); + CAPTURE(query, matches, unknownVersion); + SearchAndManifestResponses responses; + responses.SearchResponse[L"Data"][0][L"PackageName"] = web::json::value::string(L"Unrelated application"); + if (unknownVersion) + { + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"Unknown"); + } + if (matches) + { + responses.ManifestResponse[L"Data"][L"Versions"][0][L"DefaultLocale"][L"PackageName"] = + web::json::value::string(ConvertToUTF16(query)); + } + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchField::PackageFamilyName, MatchType::Exact, query); + request.Inclusions.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, query); + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, query); + request.Inclusions.emplace_back(PackageMatchField::Name, MatchType::CaseInsensitive, query); + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::CaseInsensitive, query); + + auto result = rest.Search(request); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); + REQUIRE(result.Matches.size() == (matches ? size_t{ 1 } : size_t{ 0 })); + if (matches) + { + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + REQUIRE(result.Matches[0].Versions[0].VersionAndChannel.GetVersion().ToString() == "1.0.0"); + } +} + +TEST_CASE("Search_ManifestResolution_SkipsUnnecessaryLookups", "[RestSource][Interface_1_0]") +{ + SearchAndManifestResponses responses; + responses.ManifestStatus = web::http::status_codes::ServiceUnavailable; + SearchRequest request; + size_t expectedCount = 1; + SECTION("No selectors") {} + SECTION("Generic query") + { + request.Query.emplace(MatchType::Substring, "browser"); + } + SECTION("Generic query can select independently") + { + request.Query.emplace(MatchType::Substring, "browser"); + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "browser"); + } + SECTION("Known inclusion matches after unknown inclusion") + { + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "browser"); + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + } + SECTION("Known filter fails after unknown filter") + { + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "browser"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expectedCount = 0; + } + SECTION("Known inclusions fail despite unknown filter") + { + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "browser"); + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expectedCount = 0; + } + SECTION("Unsupported comparison") + { + auto type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + request.Filters.emplace_back(PackageMatchField::Name, type, "browser"); + } + SECTION("Market remains unvalidated") + { + request.Filters.emplace_back(PackageMatchField::Market, MatchType::Exact, "value"); + } + SECTION("Normalized pair remains unvalidated") + { + request.Purpose = GENERATE(SearchPurpose::Default, SearchPurpose::CorrelationToInstalled, SearchPurpose::CorrelationToAvailable); + bool useInclusions = GENERATE(false, true); + CAPTURE(request.Purpose, useInclusions); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + auto& criteria = useInclusions ? request.Inclusions : request.Filters; + criteria.emplace_back(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, "Other Name", "Other Publisher"); + } + SECTION("Installed-package correlation") + { + request.Purpose = GENERATE(SearchPurpose::CorrelationToInstalled, SearchPurpose::CorrelationToAvailable); + request.Inclusions.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, "Missing.Code"); + } + SECTION("Returned name proves the filter") + { + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, + ConvertToUTF8(responses.SearchResponse[L"Data"][0][L"PackageName"].as_string())); + } + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + auto result = rest.Search(request); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 0); +} + +TEST_CASE("Search_ManifestResolution_Errors", "[RestSource][Interface_1_1]") +{ + SearchAndManifestResponses responses; + HRESULT expectedError = APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA; + SECTION("Mismatching package identifier") + { + responses.ManifestResponse[L"Data"][L"PackageIdentifier"] = web::json::value::string(L"Other.Package"); + } + SECTION("Malformed manifest data") + { + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array(); + } + SECTION("Invalid installer") + { + responses.ManifestResponse[L"Data"][L"Versions"][0][L"Installers"][0].as_object().erase(L"InstallerUrl"); + } + SECTION("Access denied") + { + responses.ManifestStatus = web::http::status_codes::Unauthorized; + expectedError = HTTP_E_STATUS_DENIED; + } + SECTION("Service unavailable") + { + responses.ManifestStatus = web::http::status_codes::ServiceUnavailable; + expectedError = APPINSTALLER_CLI_ERROR_SERVICE_UNAVAILABLE; + } + SECTION("Unsupported request reported by the server") + { + responses.ManifestResponse = web::json::value::parse(LR"({"Data":null,"RequiredQueryParameters":["Version"]})"); + expectedError = APPINSTALLER_CLI_ERROR_UNSUPPORTED_SOURCE_REQUEST; + } + HttpClientHelper helper{ responses.GetHandler() }; + V1_1::Interface rest{ TestRestUriString, helper, {} }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Bar"); + REQUIRE_THROWS_HR(rest.Search(request), expectedError); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); +} + +TEST_CASE("Search_ManifestResolution_Versions", "[RestSource][Interface_1_0]") +{ + SearchAndManifestResponses responses; + auto firstManifest = responses.ManifestResponse[L"Data"][L"Versions"][0]; + firstManifest[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"other"); + auto secondManifest = firstManifest; + secondManifest[L"PackageVersion"] = web::json::value::string(L"2.0.0"); + secondManifest[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"target"); + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstManifest, secondManifest }); + responses.SearchResponse[L"Data"][0][L"Versions"][1][L"PackageVersion"] = web::json::value::string(L"2.0.0"); + size_t expectedCount = 1; + bool secondManifestCached = true; + std::string expectedVersion = "2.0.0"; + std::string expectedChannel; + + SECTION("A different version can match") {} + SECTION("Identifier casing is not a different package") + { + responses.ManifestResponse[L"Data"][L"PackageIdentifier"] = web::json::value::string(L"foo.bar"); + } + SECTION("Canonically equivalent identifiers are the same package") + { + responses.SearchResponse[L"Data"][0][L"PackageIdentifier"] = web::json::value::string(L"Foo.Cafe\u0301"); + responses.ManifestResponse[L"Data"][L"PackageIdentifier"] = web::json::value::string(L"Foo.Caf\u00E9"); + } + SECTION("An omitted version keeps the result unknown") + { + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstManifest }); + secondManifestCached = false; + } + SECTION("A manifest from another channel cannot fill the missing version") + { + responses.SearchResponse[L"Data"][0][L"Versions"][1][L"Channel"] = web::json::value::string(L"beta"); + expectedChannel = "beta"; + secondManifestCached = false; + } + SECTION("Version matching ignores casing") + { + expectedVersion = "2.0.0-BETA"; + responses.SearchResponse[L"Data"][0][L"Versions"][1][L"PackageVersion"] = web::json::value::string(ConvertToUTF16(expectedVersion)); + responses.ManifestResponse[L"Data"][L"Versions"][1][L"PackageVersion"] = web::json::value::string(L"2.0.0-beta"); + } + SECTION("Version matching does not normalize version parts") + { + expectedVersion = "2.0"; + responses.SearchResponse[L"Data"][0][L"Versions"][1][L"PackageVersion"] = web::json::value::string(ConvertToUTF16(expectedVersion)); + secondManifestCached = false; + } + SECTION("All versions reject the request") + { + responses.ManifestResponse[L"Data"][L"Versions"][1][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"other"); + expectedCount = 0; + } + SECTION("A version outside the search result cannot satisfy the request") + { + responses.ManifestResponse[L"Data"][L"Versions"][1][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"other"); + secondManifest[L"PackageVersion"] = web::json::value::string(L"3.0.0"); + responses.ManifestResponse[L"Data"][L"Versions"][2] = secondManifest; + expectedCount = 0; + } + + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "target"); + auto result = rest.Search(request); + REQUIRE(responses.ManifestRequests == 1); + REQUIRE(result.Matches.size() == expectedCount); + if (expectedCount) + { + const auto& versions = result.Matches[0].Versions; + REQUIRE(versions.size() == 2); + REQUIRE(versions[0].Manifest.has_value()); + REQUIRE(versions[1].Manifest.has_value() == secondManifestCached); + REQUIRE(versions[1].VersionAndChannel.GetVersion().ToString() == expectedVersion); + REQUIRE(versions[1].VersionAndChannel.GetChannel().ToString() == expectedChannel); + } +} + +TEST_CASE("Search_ManifestResolution_ReusesPackageCache", "[RestSource]") +{ + auto [unknownVersion, cacheDuringSearch] = GENERATE( + std::make_pair(false, true), std::make_pair(true, false), std::make_pair(true, true)); + CAPTURE(unknownVersion, cacheDuringSearch); + SearchAndManifestResponses responses; + if (unknownVersion) + { + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"Unknown"); + } + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageFamilyNames"][0] = web::json::value::string(L"Search.Reference_123"); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"ProductCodes"][0] = web::json::value::string(L"Search.Product"); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"UpgradeCodes"][0] = web::json::value::string(L"Search.Upgrade"); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"AppsAndFeaturesEntryVersions"][0] = web::json::value::string(L"0.5.0"); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"AppsAndFeaturesEntryVersions"][1] = web::json::value::string(L"0.6.0"); + responses.ManifestResponse[L"Data"][L"Versions"][0][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"bar"); + HttpClientHelper helper{ responses.GetHandler() }; + IRestClient::Information information{ "TestSource", { "1.4.0" } }; + SourceDetails details; + details.Identifier = "TestSource"; + auto source = std::make_shared(details, SourceInformation{}, + RestClient::Create(TestRestUriString, {}, {}, helper, information)); + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, cacheDuringSearch ? "Bar"sv : "Microsoft Teams"sv); + request.Filters.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, "Search.Product"); + if (cacheDuringSearch) + { + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "bar"); + } + + auto result = source->Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(responses.ManifestRequests == (cacheDuringSearch ? size_t{ 1 } : size_t{ 0 })); + auto package = result.Matches[0].Package->GetAvailable().at(0); + REQUIRE(package->GetLatestVersion()->GetManifest().Moniker == "bar"); + auto keys = package->GetVersionKeys(); + REQUIRE(keys.size() == 1); + REQUIRE(keys[0].Version == "1.0.0"); + REQUIRE(package->GetVersion(keys[0])->GetManifest().Moniker == "bar"); + REQUIRE(package->GetLatestVersion()->GetManifest().Version == "1.0.0"); + auto references = package->GetMultiProperty(PackageMultiProperty::PackageFamilyName); + REQUIRE(references.size() == 1); + REQUIRE(references[0].get() == "Search.Reference_123"); + auto packageProductCodes = package->GetMultiProperty(PackageMultiProperty::ProductCode); + REQUIRE(packageProductCodes.size() == 1); + REQUIRE(packageProductCodes[0].get() == "Search.Product"); + auto packageUpgradeCodes = package->GetMultiProperty(PackageMultiProperty::UpgradeCode); + REQUIRE(packageUpgradeCodes.size() == 1); + REQUIRE(packageUpgradeCodes[0].get() == "Search.Upgrade"); + auto versionReferences = package->GetLatestVersion()->GetMultiProperty(PackageVersionMultiProperty::PackageFamilyName); + auto productCodes = package->GetLatestVersion()->GetMultiProperty(PackageVersionMultiProperty::ProductCode); + auto upgradeCodes = package->GetLatestVersion()->GetMultiProperty(PackageVersionMultiProperty::UpgradeCode); + if (unknownVersion) + { + REQUIRE(versionReferences.empty()); + REQUIRE(productCodes.empty()); + REQUIRE(upgradeCodes.empty()); + } + else + { + REQUIRE(versionReferences.size() == 1); + REQUIRE(versionReferences[0].get() == "Search.Reference_123"); + REQUIRE(productCodes.size() == 1); + REQUIRE(productCodes[0].get() == "Search.Product"); + REQUIRE(upgradeCodes.size() == 1); + REQUIRE(upgradeCodes[0].get() == "Search.Upgrade"); + } + REQUIRE(package->GetLatestVersion()->GetProperty(PackageVersionProperty::ArpMinVersion).get() == (unknownVersion ? "" : "0.5.0")); + REQUIRE(package->GetLatestVersion()->GetProperty(PackageVersionProperty::ArpMaxVersion).get() == (unknownVersion ? "" : "0.6.0")); + auto pairs = package->GetMatrixProperty(PackageMatrixProperty::NormalizedNameAndPublisher); + for (const auto& pair : pairs) + { + REQUIRE(pair.size() == 2); + } + auto containsPair = [&](const auto& name, const auto& publisher) + { + return std::any_of(pairs.begin(), pairs.end(), [&](const auto& pair) { - "PackageIdentifier": "foo.package", - "PackageName": "package", - "Publisher": "foo", - "Versions": [ - { "PackageVersion": "1.0.0" }] - }], - "ContinuationToken" : "abcd-ct=" - })delimiter"); + return pair[0] == ConvertToUTF8(name.as_string()) && + pair[1] == ConvertToUTF8(publisher.as_string()); + }); + }; + const auto& searchPackage = responses.SearchResponse.at(L"Data")[0]; + const auto& locale = responses.ManifestResponse.at(L"Data").at(L"Versions")[0].at(L"DefaultLocale"); + REQUIRE(containsPair(searchPackage.at(L"PackageName"), searchPackage.at(L"Publisher"))); + REQUIRE(containsPair(locale.at(L"PackageName"), locale.at(L"Publisher"))); + REQUIRE_FALSE(containsPair(searchPackage.at(L"PackageName"), locale.at(L"Publisher"))); + REQUIRE_FALSE(containsPair(locale.at(L"PackageName"), searchPackage.at(L"Publisher"))); + REQUIRE_THROWS_HR(package->GetMatrixProperty(static_cast(-1)), E_UNEXPECTED); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); +} - HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, std::move(sample)) }; +TEST_CASE("RestSource_UnknownVersion_PackageReferences", "[RestSource]") +{ + bool cacheDuringSearch = GENERATE(false, true); + CAPTURE(cacheDuringSearch); + const std::string firstCode = "{00000000-0000-0000-0000-000000000001}"; + const std::string secondCode = "{00000000-0000-0000-0000-000000000002}"; + const std::string searchOnlyCode = "{00000000-0000-0000-0000-000000000003}"; + SearchAndManifestResponses responses; + auto& searchVersion = responses.SearchResponse[L"Data"][0][L"Versions"][0]; + searchVersion[L"PackageVersion"] = web::json::value::string(L"Unknown"); + searchVersion[L"ProductCodes"] = web::json::value::array({ + web::json::value::string(ConvertToUTF16(firstCode)), web::json::value::string(ConvertToUTF16(searchOnlyCode)) }); + auto firstManifest = responses.ManifestResponse[L"Data"][L"Versions"][0]; + firstManifest[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"bar"); + firstManifest[L"Installers"][0][L"ProductCode"] = web::json::value::string(ConvertToUTF16(firstCode)); + auto secondManifest = firstManifest; + secondManifest[L"PackageVersion"] = web::json::value::string(L"2.0.0"); + secondManifest[L"Installers"][0][L"ProductCode"] = web::json::value::string(ConvertToUTF16(secondCode)); + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstManifest, secondManifest }); + HttpClientHelper helper{ responses.GetHandler() }; + SourceDetails details; + details.Identifier = "TestSource"; + auto source = std::make_shared(details, SourceInformation{}, + RestClient::Create(TestRestUriString, {}, {}, helper, IRestClient::Information{ details.Identifier, { "1.4.0" } })); + SearchRequest request; + request.Filters.emplace_back(cacheDuringSearch ? PackageMatchField::Moniker : PackageMatchField::Name, + MatchType::Exact, cacheDuringSearch ? "bar"sv : "Microsoft Teams"sv); + + auto result = source->Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(responses.ManifestRequests == (cacheDuringSearch ? size_t{ 1 } : size_t{ 0 })); + auto package = result.Matches[0].Package->GetAvailable().at(0); + REQUIRE(package->GetLatestVersion()->GetManifest().Version == "2.0.0"); + auto productCodes = package->GetMultiProperty(PackageMultiProperty::ProductCode); + REQUIRE(productCodes.size() == 3); + CHECK(productCodes[0].get() == firstCode); + CHECK(productCodes[1].get() == secondCode); + CHECK(productCodes[2].get() == searchOnlyCode); + auto keys = package->GetVersionKeys(); + REQUIRE(keys.size() == 2); + for (const auto& key : keys) + { + auto versionCodes = package->GetVersion(key)->GetMultiProperty(PackageVersionMultiProperty::ProductCode); + REQUIRE(versionCodes.size() == 1); + CHECK(versionCodes[0].get() == (key.Version == "1.0.0" ? firstCode : secondCode)); + } + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); +} + +TEST_CASE("Search_ManifestResolution_UnknownVersionChannel", "[RestSource][Interface_1_0]") +{ + std::string channel = GENERATE("", "preview"); + CAPTURE(channel); + SearchAndManifestResponses responses; + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"Unknown"); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"Channel"] = web::json::value::string(ConvertToUTF16(channel)); + responses.SearchResponse[L"Data"][0][L"Versions"][0][L"ProductCodes"][0] = web::json::value::string(L"Search.Product"); + auto firstManifest = responses.ManifestResponse[L"Data"][L"Versions"][0]; + firstManifest[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"target"); + firstManifest[L"Installers"][0][L"ProductCode"] = web::json::value::string(L"{00000000-0000-0000-0000-000000000001}"); + auto secondManifest = firstManifest; + secondManifest[L"PackageVersion"] = web::json::value::string(L"2.0.0"); + secondManifest[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"other"); + secondManifest[L"Installers"][0][L"ProductCode"] = web::json::value::string(L"{00000000-0000-0000-0000-000000000002}"); + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstManifest, secondManifest }); + size_t expectedCount = 1; + bool manifestFound = true; + + SECTION("All retrieved versions replace the unknown entry") {} + SECTION("Nonmatching manifests reject the candidate") + { + responses.ManifestResponse[L"Data"][L"Versions"][0][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"other"); + expectedCount = 0; + } + SECTION("Missing manifests preserve the unknown version") + { + responses.SetManifestNotFound(); + manifestFound = false; + } + + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "target"); + + auto result = rest.Search(request); + REQUIRE(result.Matches.size() == expectedCount); + if (expectedCount) + { + const auto& versions = result.Matches[0].Versions; + if (manifestFound) + { + REQUIRE(versions.size() == 2); + REQUIRE(versions[0].Manifest.has_value()); + REQUIRE(versions[0].VersionAndChannel.GetVersion().ToString() == "1.0.0"); + REQUIRE(versions[0].VersionAndChannel.GetChannel().ToString().empty()); + REQUIRE(versions[0].ProductCodes == std::vector{ "{00000000-0000-0000-0000-000000000001}" }); + REQUIRE(versions[1].Manifest.has_value()); + REQUIRE(versions[1].VersionAndChannel.GetVersion().ToString() == "2.0.0"); + REQUIRE(versions[1].VersionAndChannel.GetChannel().ToString().empty()); + REQUIRE(versions[1].ProductCodes == std::vector{ "{00000000-0000-0000-0000-000000000002}" }); + } + else + { + REQUIRE(versions.size() == 1); + REQUIRE_FALSE(versions[0].Manifest.has_value()); + REQUIRE(versions[0].VersionAndChannel.GetVersion().IsUnknown()); + REQUIRE(versions[0].VersionAndChannel.GetChannel().ToString() == channel); + REQUIRE(versions[0].ProductCodes == std::vector{ "Search.Product" }); + } + } + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 1); +} + +TEST_CASE("RestSource_MatrixProperty_DeduplicatesNameAndPublisher", "[RestSource]") +{ + bool cacheManifests = GENERATE(false, true); + CAPTURE(cacheManifests); + SearchAndManifestResponses responses; + auto& searchPackage = responses.SearchResponse[L"Data"][0]; + searchPackage[L"PackageName"] = web::json::value::string(L"Bar"); + searchPackage[L"Publisher"] = web::json::value::string(L"Foo"); + searchPackage[L"Versions"][1][L"PackageVersion"] = web::json::value::string(L"2.0.0"); + auto firstVersion = responses.ManifestResponse[L"Data"][L"Versions"][0]; + firstVersion[L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"bar"); + firstVersion[L"Locales"] = web::json::value::parse(LR"([ + { "PackageLocale": "fr-FR", "PackageName": "Bar", "Publisher": "Foo" }, + { "PackageLocale": "de-DE", "PackageName": "Other Name", "Publisher": "Foo" }, + { "PackageLocale": "es-ES", "PackageName": "Bar", "Publisher": "Other Publisher" }, + { "PackageLocale": "ja-JP", "PackageName": "bar", "Publisher": "Foo" }, + { "PackageLocale": "nl-NL", "PackageName": "Bar", "Publisher": "foo" } + ])"); + firstVersion[L"Installers"][0][L"AppsAndFeaturesEntries"] = web::json::value::parse(LR"([ + { "DisplayName": "Bar", "Publisher": "Foo" }, + { "DisplayName": "Installed Name", "Publisher": "Installed Publisher" }, + { "DisplayName": "Installed Name", "Publisher": "Installed Publisher" }, + { "DisplayName": "Other Name" } + ])"); + auto secondVersion = firstVersion; + secondVersion[L"PackageVersion"] = web::json::value::string(L"2.0.0"); + firstVersion[L"Installers"][0][L"AppsAndFeaturesEntries"][4] = web::json::value::parse( + LR"({ "DisplayName": "Older Name", "Publisher": "Older Publisher" })"); + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstVersion, secondVersion }); + HttpClientHelper helper{ responses.GetHandler() }; + IRestClient::Information information{ "TestSource", { "1.4.0" } }; + SourceDetails details; + details.Identifier = "TestSource"; + auto source = std::make_shared(details, SourceInformation{}, + RestClient::Create(TestRestUriString, {}, {}, helper, information)); + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Bar"); + if (cacheManifests) + { + request.Filters.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "bar"); + } + + auto result = source->Search(request); + REQUIRE(result.Matches.size() == 1); + auto package = result.Matches[0].Package->GetAvailable().at(0); + REQUIRE(package->GetVersionKeys().size() == 2); + std::vector> expected{ { "Bar", "Foo" } }; + if (cacheManifests) + { + expected.insert(expected.end(), { + { "Other Name", "Foo" }, + { "Bar", "Other Publisher" }, + { "bar", "Foo" }, + { "Bar", "foo" }, + { "Installed Name", "Installed Publisher" }, + { "Older Name", "Older Publisher" }, + }); + } + for (size_t attempt = 0; attempt < 2; ++attempt) + { + CAPTURE(attempt); + REQUIRE(package->GetMatrixProperty(PackageMatrixProperty::NormalizedNameAndPublisher) == expected); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == (cacheManifests ? size_t{ 1 } : size_t{ 0 })); + } +} + +TEST_CASE("Search_ManifestResolution_SourceCapabilities", "[RestSource][Interface_1_1]") +{ + SearchAndManifestResponses responses; + IRestClient::Information information; + information.RequiredPackageMatchFields = { "Market" }; + information.RequiredQueryParameters = { "Market" }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Bar"); + std::string expectedMarket = AppInstaller::Runtime::GetOSRegion(); + size_t expectedManifestRequests = 1; + size_t expectedQueryValidations = 1; + SECTION("Use the required market") {} + SECTION("Preserve an explicit market") + { + expectedMarket = "FR"; + request.Filters.emplace_back(PackageMatchField::Market, MatchType::Exact, expectedMarket); + } + SECTION("Conflicting markets cannot be represented") + { + request.Filters.emplace_back(PackageMatchField::Market, MatchType::Exact, "FR"); + request.Filters.emplace_back(PackageMatchField::Market, MatchType::Exact, "DE"); + expectedManifestRequests = 0; + expectedQueryValidations = 0; + } + SECTION("A market prefix cannot be represented") + { + request.Filters.emplace_back(PackageMatchField::Market, MatchType::StartsWith, "F"); + expectedManifestRequests = 0; + expectedQueryValidations = 0; + } + SECTION("Required version prevents an all-manifests lookup") + { + information.RequiredQueryParameters.emplace_back("Version"); + expectedManifestRequests = 0; + } + SECTION("An unsupported market prevents lookup") + { + information.UnsupportedQueryParameters.emplace_back("Market"); + expectedManifestRequests = 0; + } + SECTION("Unsupported version and channel parameters are not sent") + { + information.UnsupportedQueryParameters = { "Version", "Channel" }; + } + HttpClientHelper helper{ responses.GetHandler() }; + QueryValidationTrackingInterface rest{ TestRestUriString, helper, information, { { L"Windows-Package-Manager", L"TestHeader" } } }; + auto result = rest.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == expectedManifestRequests); + REQUIRE(rest.QueryValidations == expectedQueryValidations); + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value() == (expectedManifestRequests != 0)); + if (expectedManifestRequests) + { + auto query = web::uri::split_query(responses.LastManifestRequest.absolute_uri().query()); + REQUIRE(query.at(L"Market") == ConvertToUTF16(expectedMarket)); + REQUIRE(query.count(L"Version") == 0); + REQUIRE(query.count(L"Channel") == 0); + REQUIRE(responses.LastManifestRequest.headers()[L"Windows-Package-Manager"] == L"TestHeader"); + REQUIRE(responses.LastManifestRequest.headers()[L"Version"] == L"1.1.0"); + } +} + +TEST_CASE("Search_ManifestResolution_Continuation", "[RestSource][Interface_1_0]") +{ + bool exceedsResultLimit = GENERATE(false, true); + CAPTURE(exceedsResultLimit); + size_t searches = 0; + size_t lookups = 0; + std::vector tokens; + bool manifestReceivedContinuation = false; + bool manifestBeforeSearchComplete = false; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::OK }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + tokens.emplace_back(request.headers()[L"ContinuationToken"]); + ++searches; + response.set_body(web::json::value::parse(searches == 1 ? + GetSearchResponse_PackageIds({ L"Other.Package" }, L"next") : + (exceedsResultLimit ? + GetSearchResponse_PackageIds({ L"Other.Second", L"Other.Third", L"Foo.Bar" }, L"more") : + GetSearchResponse_PackageIds({ L"Foo.Bar" })))); + } + else if (request.method() == web::http::methods::GET) + { + ++lookups; + manifestReceivedContinuation |= request.headers().has(L"ContinuationToken"); + manifestBeforeSearchComplete |= searches < 2; + auto manifest = web::json::value::parse(GetGoodManifest_RequiredFields()); + auto packageId = web::uri::split_path(request.absolute_uri().path()).back(); + manifest[L"Data"][L"PackageIdentifier"] = web::json::value::string(packageId); + manifest[L"Data"][L"Versions"][0][L"PackageVersion"] = web::json::value::string(L"1.0.0"); + if (packageId == L"Foo.Bar") + { + manifest[L"Data"][L"Versions"][0][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"bar"); + } + response.set_body(manifest); + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::Exact, "bar"); + request.MaximumResults = 1; + auto result = rest.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == (exceedsResultLimit ? "Other.Package" : "Foo.Bar")); + REQUIRE(result.Truncated == exceedsResultLimit); + REQUIRE(searches == 2); + REQUIRE(lookups == (exceedsResultLimit ? size_t{ 0 } : size_t{ 2 })); + REQUIRE(tokens == std::vector{ L"", L"next" }); + REQUIRE_FALSE(manifestReceivedContinuation); + REQUIRE_FALSE(manifestBeforeSearchComplete); +} + +TEST_CASE("Search_ManifestResolution_ResultLimit", "[RestSource][Interface_1_0]") +{ + size_t packageCount = GENERATE(size_t{ 3 }, size_t{ 4 }); + bool paginated = GENERATE(false, true); + bool manifestAvailable = GENERATE(false, true); + bool manifestMatches = GENERATE(false, true); + bool useInclusions = GENERATE(false, true); + size_t maximumResults = GENERATE(size_t{ 0 }, size_t{ 1 }); + CAPTURE(packageCount, paginated, manifestAvailable, manifestMatches, useInclusions, maximumResults); + const std::vector identifiers{ "Foo.One", "Foo.Two", "Foo.Three", "Foo.Four" }; + std::vector pages; + if (paginated) + { + pages.emplace_back(GetSearchResponse_PackageIds({ L"Foo.One", L"Foo.Two" }, L"next")); + pages.emplace_back(packageCount == 3 ? + GetSearchResponse_PackageIds({ L"Foo.Three" }) : + GetSearchResponse_PackageIds({ L"Foo.Three", L"Foo.Four" })); + } + else + { + pages.emplace_back(packageCount == 3 ? + GetSearchResponse_PackageIds({ L"Foo.One", L"Foo.Two", L"Foo.Three" }) : + GetSearchResponse_PackageIds({ L"Foo.One", L"Foo.Two", L"Foo.Three", L"Foo.Four" })); + } + size_t searches = 0; + std::vector manifestPaths; + bool manifestBeforeSearchComplete = false; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::OK }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + response.set_body(web::json::value::parse(pages.at(searches++))); + } + else if (request.method() == web::http::methods::GET) + { + auto path = request.absolute_uri().path(); + manifestPaths.emplace_back(path); + manifestBeforeSearchComplete |= searches != pages.size(); + if (manifestAvailable) + { + auto manifest = web::json::value::parse(GetGoodManifest_RequiredFields()); + manifest[L"Data"][L"PackageIdentifier"] = web::json::value::string(web::uri::split_path(path).back()); + auto& version = manifest[L"Data"][L"Versions"][0]; + version[L"PackageVersion"] = web::json::value::string(L"1.0.0"); + version[L"DefaultLocale"][L"PackageName"] = web::json::value::string(manifestMatches ? L"Wanted" : L"Other"); + response.set_body(manifest); + } + else + { + response.set_status_code(web::http::status_codes::NotFound); + response.set_body(web::json::value::parse(LR"({"code":"DataNotFound","message":"Not found"})")); + } + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + auto& criteria = useInclusions ? request.Inclusions : request.Filters; + criteria.emplace_back(PackageMatchField::Name, MatchType::Exact, "Wanted"); + request.MaximumResults = maximumResults; + + for (size_t attempt = 0; attempt < 2; ++attempt) + { + CAPTURE(attempt); + searches = 0; + manifestPaths.clear(); + manifestBeforeSearchComplete = false; + auto result = rest.Search(request); + bool enrich = packageCount <= 3; + size_t expectedLookups = enrich ? packageCount : 0; + REQUIRE(manifestPaths.size() == expectedLookups); + REQUIRE_FALSE(manifestBeforeSearchComplete); + for (size_t i = 0; i < expectedLookups; ++i) + { + REQUIRE(manifestPaths[i] == L"/api/packageManifests/" + ConvertToUTF16(identifiers[i])); + } + + size_t expectedCount = enrich && manifestAvailable && !manifestMatches ? 0 : packageCount; + bool expectedTruncated = maximumResults && expectedCount > maximumResults; + if (maximumResults) + { + expectedCount = std::min(expectedCount, maximumResults); + } + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(result.Truncated == expectedTruncated); + REQUIRE(searches == pages.size()); + for (size_t i = 0; i < expectedCount; ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == identifiers[i]); + REQUIRE(result.Matches[i].Versions[0].Manifest.has_value() == (enrich && manifestAvailable)); + } + } +} + +TEST_CASE("Search_ManifestResolution_ResultLimit_AvailableMetadata", "[RestSource][Interface_1_0]") +{ + SearchAndManifestResponses responses; + responses.SearchResponse = web::json::value::parse(GetSearchResponse_PackageIds( + { L"Other.First", L"Foo.Known", L"Foo.One", L"Foo.Two", L"Foo.Three", L"Other.Last", L"Foo.Last", L"Foo.Unknown" })); + for (size_t index : { size_t{ 1 }, size_t{ 5 }, size_t{ 6 } }) + { + responses.SearchResponse[L"Data"][index][L"PackageName"] = web::json::value::string(L"Wanted"); + } + responses.SetManifestNotFound(); + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Wanted"); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Foo."); + std::vector expected{ "Foo.Known", "Foo.One", "Foo.Two", "Foo.Three", "Foo.Last", "Foo.Unknown" }; + + SECTION("Available metadata still filters larger result sets") {} + SECTION("Known mismatches do not reduce the source result count") + { + request.Filters[1].Type = MatchType::Exact; + request.Filters[1].Value = "Foo.Unknown"; + expected = { "Foo.Unknown" }; + } + + auto result = rest.Search(request); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 0); + REQUIRE_FALSE(result.Truncated); + REQUIRE(result.Matches.size() == expected.size()); + for (size_t i = 0; i < expected.size(); ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == expected[i]); + } +} + +TEST_CASE("Search_ManifestResolution_ResultLimit_SearchRequest", "[RestSource][Interface_1_0]") +{ + size_t maximumResults = GENERATE(size_t{ 0 }, size_t{ 1 }, size_t{ 3 }, size_t{ 4 }); + CAPTURE(maximumResults); + SearchAndManifestResponses responses; + SearchRequest request; + request.MaximumResults = maximumResults; + request.Filters.emplace_back(PackageMatchField::Name, MatchType::Exact, "Microsoft Teams"); + size_t expectedMaximumResults = maximumResults ? std::max(maximumResults, size_t{ 4 }) : 0; + + SECTION("Request enough candidates to detect a larger result set") {} + SECTION("ID-only criteria do not require extra candidates") + { + request.Filters.clear(); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Foo."); + expectedMaximumResults = maximumResults; + } + SECTION("Generic queries do not require extra candidates") + { + request.Filters.clear(); + request.Query.emplace(MatchType::Substring, "Teams"); + expectedMaximumResults = maximumResults; + } + SECTION("Correlation does not require extra candidates") + { + request.Purpose = SearchPurpose::CorrelationToAvailable; + expectedMaximumResults = maximumResults; + } + SECTION("Unsupported comparisons do not require extra candidates") + { + request.Filters[0].Type = MatchType::Fuzzy; + expectedMaximumResults = maximumResults; + } + SECTION("Unverifiable fields do not require extra candidates") + { + request.Filters.clear(); + request.Filters.emplace_back(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, "Microsoft Teams", "Microsoft"); + expectedMaximumResults = maximumResults; + } + + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + auto result = rest.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE_FALSE(result.Truncated); + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == 0); + REQUIRE(request.MaximumResults == maximumResults); + auto searchBody = responses.LastSearchRequest.extract_json().get(); + REQUIRE(searchBody.has_field(L"MaximumResults") == (maximumResults != 0)); + if (maximumResults) + { + REQUIRE(searchBody.at(L"MaximumResults").as_number().to_uint64() == expectedMaximumResults); + } +} + +TEST_CASE("Search_ExplicitIdFilters_UnsupportedMatchType", "[RestSource][Interface_1_0]") +{ + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"Foo.Bar" })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + auto type = GENERATE(MatchType::Fuzzy, MatchType::FuzzySubstring, MatchType::Wildcard); + request.Filters.emplace_back(PackageMatchField::Id, type, "Other"); + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); +} + +TEST_CASE("Search_ExplicitIdFilters_UnavailableMetadata", "[RestSource][Interface_1_0]") +{ + SearchAndManifestResponses responses; + responses.SetManifestNotFound(); + HttpClientHelper helper{ responses.GetHandler() }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Exact, "Foo.Bar"); + auto field = GENERATE(PackageMatchField::Name, PackageMatchField::Moniker, PackageMatchField::Tag, + PackageMatchField::Command, PackageMatchField::PackageFamilyName, PackageMatchField::ProductCode, + PackageMatchField::UpgradeCode, PackageMatchField::NormalizedNameAndPublisher, PackageMatchField::Market); + request.Filters.emplace_back(field, MatchType::Exact, "Not in the response"); + + auto result = v1.Search(request); + size_t expectedManifestRequests = (field == PackageMatchField::NormalizedNameAndPublisher || field == PackageMatchField::Market) ? 0 : 1; + REQUIRE(responses.SearchRequests == 1); + REQUIRE(responses.ManifestRequests == expectedManifestRequests); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); +} + +TEST_CASE("Search_ExplicitIdFilters_NoFilters", "[RestSource][Interface_1_0]") +{ + HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, + GetSearchResponse_PackageIds({ L"Foo.Bar" })) }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + size_t expectedCount = 1; + + SECTION("Everything") {} + SECTION("Query") + { + request.Query.emplace(MatchType::Exact, "Not in the response"); + } + SECTION("Inclusions") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Not in the response"); + expectedCount = 0; + } + SECTION("Correlation") + { + request.Purpose = SearchPurpose::CorrelationToAvailable; + request.Inclusions.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, "Not in the response"); + } + + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == expectedCount); + if (expectedCount) + { + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); + } +} + +TEST_CASE("Search_ExplicitIdFilters_Continuation", "[RestSource][Interface_1_0]") +{ + bool allFiltered = GENERATE(false, true); + bool useInclusions = GENERATE(false, true); + CAPTURE(allFiltered, useInclusions); + std::vector pages + { + GetSearchResponse_PackageIds({ L"Other.One", L"Other.Two" }, L"next"), + GetSearchResponse_PackageIds({ allFiltered ? L"Other.Three" : L"Match.One" }, L"last"), + GetSearchResponse_PackageIds({ allFiltered ? L"Other.Four" : L"Match.Two", + allFiltered ? L"Other.Five" : L"Match.Three" }), + }; + std::vector continuationTokens; + size_t requestCount = 0; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST && requestCount < pages.size()) + { + continuationTokens.emplace_back(request.headers()[L"ContinuationToken"]); + response.set_status_code(web::http::status_codes::OK); + response.set_body(web::json::value::parse(pages[requestCount])); + } + ++requestCount; + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + auto& criteria = useInclusions ? request.Inclusions : request.Filters; + criteria.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Match."); + request.MaximumResults = GENERATE(0, 1, 2, 3, 9); + + auto result = v1.Search(request); + size_t expectedCount = allFiltered ? 0 : (request.MaximumResults ? std::min(size_t{ 3 }, request.MaximumResults) : 3); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE(result.Truncated == (!allFiltered && expectedCount < 3)); + REQUIRE(requestCount == (!allFiltered && request.MaximumResults == 1 ? size_t{ 2 } : size_t{ 3 })); + REQUIRE(continuationTokens[0].empty()); + REQUIRE(continuationTokens[1] == L"next"); + if (requestCount == 3) + { + REQUIRE(continuationTokens[2] == L"last"); + } + const std::vector expectedIds{ "Match.One", "Match.Two", "Match.Three" }; + for (size_t i = 0; i < expectedCount; ++i) + { + REQUIRE(result.Matches[i].PackageInformation.PackageIdentifier == expectedIds[i]); + } +} + +TEST_CASE("Search_ContinuationToken", "[RestSource][Interface_1_0]") +{ + size_t requestCount = 0; + std::vector sentTokens; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + sentTokens.emplace_back(request.headers()[L"ContinuationToken"]); + ++requestCount; + response.set_status_code(web::http::status_codes::OK); + response.set_body(web::json::value::parse( + GetSearchResponse_PackageIds({ L"git.package", L"foo.package" }, std::to_wstring(requestCount)))); + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; Interface v1{ TestRestUriString, std::move(helper) }; - SearchRequest request{}; - request.MaximumResults = 9; - Schema::IRestClient::SearchResult results = v1.Search(request); - REQUIRE(results.Matches.size() == request.MaximumResults); + for (size_t maximumResults : { size_t{ 9 }, size_t{ 1 }, size_t{ 9 } }) + { + CAPTURE(maximumResults); + requestCount = 0; + sentTokens.clear(); + SearchRequest request; + request.MaximumResults = maximumResults; + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == maximumResults); + REQUIRE(result.Truncated); + REQUIRE(requestCount == (maximumResults + 1) / 2); + REQUIRE(sentTokens[0].empty()); + for (size_t i = 1; i < sentTokens.size(); ++i) + { + REQUIRE(sentTokens[i] == std::to_wstring(i)); + } + } +} + +TEST_CASE("Search_ContinuationToken_Cycle", "[RestSource][Interface_1_0]") +{ + bool longerCycle = GENERATE(false, true); + CAPTURE(longerCycle); + std::vector returnedTokens{ L"next", L"next" }; + if (longerCycle) + { + returnedTokens.insert(returnedTokens.begin() + 1, L"NEXT"); + } + bool keepFirstResult = false; + bool reachResultLimit = false; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, MatchType::StartsWith, "Match."); + request.MaximumResults = 1; - SearchRequest requestWithSize1{}; - requestWithSize1.MaximumResults = 1; - Schema::IRestClient::SearchResult resultsWithSize1 = v1.Search(requestWithSize1); - REQUIRE(resultsWithSize1.Matches.size() == requestWithSize1.MaximumResults); + SECTION("All pages are rejected") {} + SECTION("Partial results do not hide the invalid response") + { + keepFirstResult = true; + request.MaximumResults = 2; + } + SECTION("Unlimited results still detect cycles") + { + request.MaximumResults = 0; + } + SECTION("A satisfied result limit does not follow the repeated token") + { + reachResultLimit = true; + } + + size_t requestCount = 0; + std::vector sentTokens; + auto handler = std::make_shared( + [&](web::http::http_request httpRequest) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (httpRequest.method() == web::http::methods::POST) + { + sentTokens.emplace_back(httpRequest.headers()[L"ContinuationToken"]); + size_t page = requestCount++; + response.set_status_code(web::http::status_codes::OK); + if (page < returnedTokens.size()) + { + bool matches = (keepFirstResult && page == 0) || (reachResultLimit && page + 1 == returnedTokens.size()); + response.set_body(web::json::value::parse( + GetSearchResponse_PackageIds({ matches ? L"Match.One" : L"Other.App" }, returnedTokens[page]))); + } + else + { + // End the fake chain if the client fails to detect the cycle. + response.set_body(web::json::value::parse(GetSearchResponse_PackageIds({}))); + } + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface v1{ TestRestUriString, helper }; + if (reachResultLimit) + { + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Match.One"); + REQUIRE(result.Truncated); + } + else + { + REQUIRE_THROWS_HR(v1.Search(request), APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + REQUIRE(requestCount == returnedTokens.size()); + REQUIRE(sentTokens[0].empty()); + for (size_t i = 1; i < sentTokens.size(); ++i) + { + REQUIRE(sentTokens[i] == returnedTokens[i - 1]); + } } TEST_CASE("Search_BadResponse_NoVersions", "[RestSource][Interface_1_0]") @@ -462,7 +1946,7 @@ TEST_CASE("Search_Optimized_ManifestResponse", "[RestSource][Interface_1_0]") utility::string_t sample = GetGoodManifest_RequiredFields(); HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, std::move(sample)) }; AppInstaller::Repository::SearchRequest request; - PackageMatchFilter filter{ PackageMatchField::Id, MatchType::Exact, "Foo" }; + PackageMatchFilter filter{ PackageMatchField::Id, MatchType::Exact, "Foo.Bar" }; request.Filters.emplace_back(std::move(filter)); Interface v1{ TestRestUriString, std::move(helper) }; Schema::IRestClient::SearchResult result = v1.Search(request); @@ -514,12 +1998,56 @@ TEST_CASE("Search_Optimized_NoResponse_NotFoundCode", "[RestSource][Interface_1_ REQUIRE_THROWS_HR(v1.Search(request), APPINSTALLER_CLI_ERROR_RESTAPI_ENDPOINT_NOT_FOUND); } +TEST_CASE("Search_Optimized_ExplicitIdFilter", "[RestSource][Interface_1_0]") +{ + auto type = GENERATE(MatchType::Exact, MatchType::CaseInsensitive); + std::string id = GENERATE("Foo.Bar", "foo.bar", "Foo", "Other.Package"); + bool manifestFound = GENERATE(false, true); + CAPTURE(ToString(type), id, manifestFound); + SearchAndManifestResponses responses; + if (!manifestFound) + { + responses.SetManifestNotFound(); + } + HttpClientHelper helper{ responses.GetHandler() }; + Interface v1{ TestRestUriString, helper }; + SearchRequest request; + request.Filters.emplace_back(PackageMatchField::Id, type, id); + + bool expected = manifestFound && (id == "Foo.Bar" || (type == MatchType::CaseInsensitive && id == "foo.bar")); + if (manifestFound && !expected) + { + REQUIRE_THROWS_HR(v1.Search(request), APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + else + { + auto result = v1.Search(request); + REQUIRE(result.Matches.size() == (expected ? size_t{ 1 } : size_t{ 0 })); + REQUIRE_FALSE(result.Truncated); + if (expected) + { + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); + REQUIRE(result.Matches[0].Versions.size() == 1); + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + } + } + REQUIRE(responses.SearchRequests == 0); + REQUIRE(responses.ManifestRequests == 1); + REQUIRE(responses.LastManifestRequest.absolute_uri().path() == L"/api/packageManifests/" + ConvertToUTF16(id)); +} + TEST_CASE("Search_SubstringIdFallback_ManifestResponse", "[RestSource][Interface_1_0]") { - utility::string_t emptySearchResponse = _XPLATSTR(R"delimiter({ "Data" : [] })delimiter"); + bool filteredSearch = GENERATE(false, true); + std::string_view id = GENERATE("Foo.Bar", "foo.bar", "Other.Id"); + bool manifestMatches = id != "Other.Id"; + CAPTURE(filteredSearch, id); + auto searchResponse = filteredSearch ? GetSearchResponse_PackageIds({ L"Unrelated.Package" }) : GetSearchResponse_PackageIds({}); + size_t searchCount = 0; + size_t manifestCount = 0; auto handler = std::make_shared( - [emptySearchResponse](web::http::http_request request) -> pplx::task + [&](web::http::http_request request) -> pplx::task { web::http::http_response response; response.headers().set_content_type(web::http::details::mime_types::application_json); @@ -527,11 +2055,13 @@ TEST_CASE("Search_SubstringIdFallback_ManifestResponse", "[RestSource][Interface if (request.method() == web::http::methods::POST) { + ++searchCount; response.set_status_code(web::http::status_codes::OK); - response.set_body(web::json::value::parse(emptySearchResponse)); + response.set_body(web::json::value::parse(searchResponse)); } else if (request.method() == web::http::methods::GET) { + ++manifestCount; response.set_status_code(web::http::status_codes::OK); response.set_body(web::json::value::parse(GetGoodManifest_RequiredFields())); } @@ -545,15 +2075,24 @@ TEST_CASE("Search_SubstringIdFallback_ManifestResponse", "[RestSource][Interface HttpClientHelper helper{ std::move(handler) }; AppInstaller::Repository::SearchRequest request; - request.Filters.emplace_back(PackageMatchFilter{ PackageMatchField::Id, MatchType::Substring, "Foo.Bar" }); + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Substring, id); Interface v1{ TestRestUriString, std::move(helper) }; - Schema::IRestClient::SearchResult result = v1.Search(request); - REQUIRE(result.Matches.size() == 1); - REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); - REQUIRE(result.Matches[0].Versions.size() == 1); - REQUIRE(result.Matches[0].Versions[0].VersionAndChannel.GetVersion().ToString() == "5.0.0"); - REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + if (manifestMatches) + { + Schema::IRestClient::SearchResult result = v1.Search(request); + REQUIRE(result.Matches.size() == 1); + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "Foo.Bar"); + REQUIRE(result.Matches[0].Versions.size() == 1); + REQUIRE(result.Matches[0].Versions[0].VersionAndChannel.GetVersion().ToString() == "5.0.0"); + REQUIRE(result.Matches[0].Versions[0].Manifest.has_value()); + } + else + { + REQUIRE_THROWS_HR(v1.Search(request), APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + REQUIRE(searchCount == 1); + REQUIRE(manifestCount == 1); } TEST_CASE("Search_SubstringId_NoFallbackWhenSearchMatches", "[RestSource][Interface_1_0]") @@ -731,3 +2270,39 @@ TEST_CASE("GetManifestByVersion_GoodResponse_MultipleVersions_VersionNotFound", std::optional manifest = v1.GetManifestByVersion("Foo.Bar", "7.0.0", ""); REQUIRE_FALSE(manifest.has_value()); } + +TEST_CASE("GetManifestByVersion_VersionAndChannelMatching", "[RestSource][Interface_1_0]") +{ + std::string version = GENERATE("5.0.0-beta", "5.0.0-BETA", "5.0-beta", "6.0.0-beta"); + std::string channel = GENERATE("", "missing"); + CAPTURE(version, channel); + SearchAndManifestResponses responses; + auto firstVersion = responses.ManifestResponse[L"Data"][L"Versions"][0]; + firstVersion[L"PackageVersion"] = web::json::value::string(L"4.0.0"); + auto secondVersion = firstVersion; + secondVersion[L"PackageVersion"] = web::json::value::string(L"5.0.0-beta"); + responses.ManifestResponse[L"Data"][L"Versions"] = web::json::value::array({ firstVersion, secondVersion }); + HttpClientHelper helper{ responses.GetHandler() }; + Interface rest{ TestRestUriString, helper }; + + auto manifest = rest.GetManifestByVersion("Foo.Bar", version, channel); + bool expected = (version == "5.0.0-beta" || version == "5.0.0-BETA") && channel.empty(); + REQUIRE(manifest.has_value() == expected); + if (expected) + { + REQUIRE(manifest->Version == "5.0.0-beta"); + REQUIRE(manifest->Channel.empty()); + } + REQUIRE(responses.SearchRequests == 0); + REQUIRE(responses.ManifestRequests == 1); + auto query = web::uri::split_query(responses.LastManifestRequest.absolute_uri().query()); + REQUIRE(query.at(L"Version") == ConvertToUTF16(version)); + if (!channel.empty()) + { + REQUIRE(query.at(L"Channel") == ConvertToUTF16(channel)); + } + else + { + REQUIRE(query.count(L"Channel") == 0); + } +} diff --git a/src/AppInstallerCLITests/RestInterface_1_1.cpp b/src/AppInstallerCLITests/RestInterface_1_1.cpp index eaefeda053..9b4a7de914 100644 --- a/src/AppInstallerCLITests/RestInterface_1_1.cpp +++ b/src/AppInstallerCLITests/RestInterface_1_1.cpp @@ -398,13 +398,64 @@ TEST_CASE("Search_GoodRequest_OnlyMarketRequired", "[RestSource][Interface_1_1]" }] })delimiter"); - HttpClientHelper helper{ GetTestRestRequestHandler(web::http::status_codes::OK, std::move(sample)) }; + web::json::value searchBody; + auto handler = std::make_shared( + [&](web::http::http_request httpRequest) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (httpRequest.method() == web::http::methods::POST) + { + searchBody = httpRequest.extract_json().get(); + response.set_status_code(web::http::status_codes::OK); + response.set_body(web::json::value::parse(sample)); + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; Interface v1_1{ TestRestUriString, std::move(helper), GetTestSourceInformation(), {} }; AppInstaller::Repository::SearchRequest request; - PackageMatchFilter filter{ PackageMatchField::Name, MatchType::Exact, "Foo" }; + PackageMatchFilter filter{ PackageMatchField::Name, MatchType::Exact, "package" }; request.Filters.emplace_back(std::move(filter)); + size_t expectedCount = 1; + + SECTION("Name filter") {} + SECTION("Matching ID filter") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "GIT.PACKAGE"); + } + SECTION("Mismatching ID filter") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "Other.Package"); + expectedCount = 0; + } + SECTION("Unsupported inclusion is removed") + { + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::CaseInsensitive, "git"); + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "GIT.PACKAGE"); + } + Schema::IRestClient::SearchResult searchResponse = v1_1.Search(request); - REQUIRE(searchResponse.Matches.size() == 1); + const auto& filters = searchBody.at(L"Filters").as_array(); + REQUIRE(filters.size() == request.Filters.size() + 1); + REQUIRE(filters.at(0).at(L"PackageMatchField").as_string() == L"PackageName"); + REQUIRE(filters.at(0).at(L"RequestMatch").at(L"KeyWord").as_string() == L"package"); + REQUIRE(filters.at(request.Filters.size()).at(L"PackageMatchField").as_string() == L"Market"); + if (!request.Inclusions.empty()) + { + REQUIRE(request.Inclusions.size() == 2); + const auto& inclusions = searchBody.at(L"Inclusions").as_array(); + REQUIRE(inclusions.size() == 1); + REQUIRE(inclusions.at(0).at(L"PackageMatchField").as_string() == L"PackageIdentifier"); + REQUIRE(inclusions.at(0).at(L"RequestMatch").at(L"KeyWord").as_string() == L"GIT.PACKAGE"); + } + REQUIRE(searchResponse.Matches.size() == expectedCount); + if (!expectedCount) + { + return; + } + Schema::IRestClient::Package package = searchResponse.Matches.at(0); REQUIRE(package.PackageInformation.PackageIdentifier.compare("git.package") == 0); REQUIRE(package.PackageInformation.Publisher.compare("git") == 0); @@ -414,6 +465,85 @@ TEST_CASE("Search_GoodRequest_OnlyMarketRequired", "[RestSource][Interface_1_1]" REQUIRE(package.Versions.at(1).VersionAndChannel.GetVersion().ToString().compare("2.0.0") == 0); } +TEST_CASE("Search_Inclusions_SourceCapabilities", "[RestSource][Interface_1_1]") +{ + utility::string_t sample = _XPLATSTR( + R"delimiter({ + "Data": [{ + "PackageIdentifier": "git.package", + "PackageName": "package", + "Publisher": "git", + "Versions": [{ "PackageVersion": "1.0.0" }] + }] + })delimiter"); + + size_t requestCount = 0; + auto handler = std::make_shared( + [&](web::http::http_request httpRequest) -> pplx::task + { + ++requestCount; + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (httpRequest.method() == web::http::methods::POST) + { + response.set_status_code(web::http::status_codes::OK); + response.set_body(web::json::value::parse(sample)); + } + return pplx::task_from_result(response); + }); + HttpClientHelper helper{ handler }; + Interface v1_1{ TestRestUriString, helper, GetTestSourceInformation(), {} }; + SearchRequest request; + request.Inclusions.emplace_back(PackageMatchField::Moniker, MatchType::CaseInsensitive, "git"); + size_t expectedCount = 1; + size_t expectedRequestCount = 1; + + SECTION("Matching supported inclusion") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::CaseInsensitive, "GIT.PACKAGE"); + } + SECTION("Unsupported inclusion cannot admit an ID mismatch") + { + request.Inclusions.emplace_back(PackageMatchField::Id, MatchType::Exact, "Other.Package"); + expectedCount = 0; + } + SECTION("Only unsupported inclusions must not become an unrestricted search") + { + expectedCount = 0; + expectedRequestCount = 0; + } + SECTION("Removed inclusions must not trigger ID fallback") + { + request.Filters.emplace_back(PackageMatchField::Id, MatchType::Substring, "git.package"); + expectedCount = 0; + expectedRequestCount = 0; + } + SECTION("A query can still select when all inclusions are unsupported") + { + request.Query.emplace(MatchType::Substring, "git"); + } + SECTION("No selectors were requested") + { + request.Inclusions.clear(); + } + SECTION("Correlation retains unverified reference matches") + { + request.Inclusions.clear(); + request.Purpose = SearchPurpose::CorrelationToAvailable; + request.Inclusions.emplace_back(PackageMatchField::ProductCode, MatchType::Exact, "Not in the response"); + } + + auto result = v1_1.Search(request); + REQUIRE(requestCount == expectedRequestCount); + REQUIRE(result.Matches.size() == expectedCount); + REQUIRE_FALSE(result.Truncated); + if (expectedCount) + { + REQUIRE(result.Matches[0].PackageInformation.PackageIdentifier == "git.package"); + } +} + TEST_CASE("GetManifests_BadRequest_UnsupportedQueryParameters", "[RestSource][Interface_1_1]") { utility::string_t sample = _XPLATSTR( diff --git a/src/AppInstallerCLITests/SourceFlow.cpp b/src/AppInstallerCLITests/SourceFlow.cpp index 485682c2e9..d6f4084c26 100644 --- a/src/AppInstallerCLITests/SourceFlow.cpp +++ b/src/AppInstallerCLITests/SourceFlow.cpp @@ -4,6 +4,8 @@ #include "WorkflowCommon.h" #include "TestHooks.h" #include "TestSettings.h" +#include "TestRestRequestHandler.h" +#include #include #include #include @@ -109,6 +111,95 @@ TEST_CASE("SourcePriority_SearchResult", "[SourcePriority][workflow]") } } +TEST_CASE("Search_ManifestResolution_BeforeSourcePriority", "[RestSource][SourcePriority][workflow]") +{ + namespace RepositoryRest = AppInstaller::Repository::Rest; + bool restMatches = GENERATE(false, true); + CAPTURE(restMatches); + auto searchResponse = web::json::value::parse(LR"({ + "Data": [{ + "PackageIdentifier": "Foo.Bar", "PackageName": "Unrelated application", "Publisher": "Foo", + "Versions": [{ "PackageVersion": "Unknown" }] + }] + })"); + auto manifestResponse = web::json::value::parse(LR"({ + "Data": { + "PackageIdentifier": "Foo.Bar", + "Versions": [{ + "PackageVersion": "1.0.0", + "DefaultLocale": { + "PackageLocale": "en-US", "PackageName": "Bar", "Publisher": "Foo", + "License": "MIT", "ShortDescription": "Example application" + }, + "Installers": [{ + "Architecture": "x64", "InstallerType": "exe", "InstallerUrl": "https://example.com/installer.exe", + "InstallerSha256": "011048877dfaef109801b3f3ab2b60afc74f3fc4f7b3430e0c897f5da1df84b6" + }] + }] + } + })"); + if (restMatches) + { + manifestResponse[L"Data"][L"Versions"][0][L"DefaultLocale"][L"Moniker"] = web::json::value::string(L"tool"); + } + size_t searches = 0; + size_t lookups = 0; + auto handler = std::make_shared( + [&](web::http::http_request request) -> pplx::task + { + web::http::http_response response{ web::http::status_codes::BadRequest }; + response.headers().set_content_type(web::http::details::mime_types::application_json); + response.headers().set_cache_control(L"no-store"); + if (request.method() == web::http::methods::POST) + { + ++searches; + response.set_status_code(web::http::status_codes::OK); + response.set_body(searchResponse); + } + else if (request.method() == web::http::methods::GET) + { + ++lookups; + response.set_status_code(web::http::status_codes::OK); + response.set_body(manifestResponse); + } + return pplx::task_from_result(response); + }); + AppInstaller::Http::HttpClientHelper helper{ handler }; + SourceDetails details; + details.Identifier = "RestSource"; + details.Priority = 10; + auto rest = std::make_shared(details, SourceInformation{}, + RepositoryRest::RestClient::Create("https://restsource.com/api", {}, {}, helper, + RepositoryRest::Schema::IRestClient::Information{ "RestSource", { "1.4.0" } })); + + std::ostringstream output; + TestContext context{ output, std::cin }; + auto previousThreadGlobals = context.SetForCurrentThread(); + context.Args.AddArg(Execution::Args::Type::Query, "tool"sv); + context << GetSearchRequestForSingle; + auto results = rest->Search(context.Get()); + REQUIRE(results.Matches.size() == (restMatches ? size_t{ 1 } : size_t{ 0 })); + + AppInstaller::Manifest::Manifest manifest; + manifest.Id = "Example.Tool"; + manifest.Version = "1.0.0"; + manifest.Moniker = "tool"; + manifest.DefaultLocalization.Add("Example Tool"); + auto otherSource = std::make_shared(); + otherSource->Details.Priority = 0; + auto otherPackage = TestCompositePackage::Make(std::vector{ manifest }, otherSource); + auto expectedPackage = restMatches ? results.Matches[0].Package : otherPackage; + results.Matches.emplace_back(otherPackage, PackageMatchFilter{ PackageMatchField::Moniker, MatchType::Exact, "tool" }); + context.Add(std::move(results)); + context << EnsureOneMatchFromSearchResult(OperationType::Install); + + INFO(output.str()); + REQUIRE(context.GetTerminationHR() == S_OK); + REQUIRE(context.Get() == expectedPackage); + REQUIRE(searches == 1); + REQUIRE(lookups == 1); +} + TEST_CASE("SourcePriority_SourceOutput", "[SourcePriority][workflow]") { GroupPolicyTestOverride policies; diff --git a/src/AppInstallerCLITests/Strings.cpp b/src/AppInstallerCLITests/Strings.cpp index 86f052cb31..5d79c23c73 100644 --- a/src/AppInstallerCLITests/Strings.cpp +++ b/src/AppInstallerCLITests/Strings.cpp @@ -136,6 +136,25 @@ TEST_CASE("CaseInsensitiveStartsWith", "[strings]") REQUIRE(!CaseInsensitiveStartsWith(" starts", "starts")); } +TEST_CASE("ICUCaseInsensitiveStartsWith", "[strings]") +{ + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "starts")); + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "STAR")); + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "STARTSWITH")); + REQUIRE(ICUCaseInsensitiveStartsWith("startswith", "")); + REQUIRE(ICUCaseInsensitiveStartsWith("", "")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith("starts", "startswith")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith("", "starts")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith("withstarts", "starts")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith(" starts", "starts")); + + REQUIRE(ICUCaseInsensitiveStartsWith(u8"Vendor.\u1E9EApp", u8"vendor.\u00DF")); + REQUIRE(ICUCaseInsensitiveStartsWith(u8"Vendor.\u00DFApp", u8"vendor.\u1E9E")); + REQUIRE(ICUCaseInsensitiveStartsWith(u8"Stra\u00DFe", "STRASSE")); + REQUIRE(ICUCaseInsensitiveStartsWith("STRASSE", u8"stra\u00DFe")); + REQUIRE_FALSE(ICUCaseInsensitiveStartsWith(u8"Vendor.\u00DFApp", "vendor.st")); +} + TEST_CASE("FoldCase", "[strings]") { REQUIRE(FoldCase(""sv) == FoldCase(""sv)); diff --git a/src/AppInstallerCLITests/YamlManifest.cpp b/src/AppInstallerCLITests/YamlManifest.cpp index 796c4d15fa..59d6e835b9 100644 --- a/src/AppInstallerCLITests/YamlManifest.cpp +++ b/src/AppInstallerCLITests/YamlManifest.cpp @@ -712,6 +712,110 @@ namespace } } +TEST_CASE("Manifest_PackageNames", "[ManifestValidation]") +{ + std::string_view defaultNameState = GENERATE("Missing", "Empty", "Present"); + CAPTURE(defaultNameState); + Manifest manifest; + REQUIRE(manifest.GetPackageNames().empty()); + REQUIRE(manifest.GetOriginalPackageNames().empty()); + if (defaultNameState != "Missing") + { + const std::string_view defaultName = defaultNameState == "Present" ? "Default Name" : ""; + manifest.DefaultLocalization.Add(defaultName); + } + manifest.Localizations.emplace_back().Add("Localized Name"); + manifest.Localizations.emplace_back().Add("localized name"); + manifest.Localizations.emplace_back().Add("Localized Name"); + manifest.Localizations.emplace_back().Add(u8"Caf\u00E9"); + manifest.Localizations.emplace_back().Add(u8"Cafe\u0301"); + manifest.Localizations.emplace_back().Add(""); + manifest.Localizations.emplace_back(); + auto& installer = manifest.Installers.emplace_back(); + installer.AppsAndFeaturesEntries.emplace_back().DisplayName = "Installed Name"; + installer.AppsAndFeaturesEntries.emplace_back().DisplayName = "Localized Name"; + installer.AppsAndFeaturesEntries.emplace_back().Publisher = "Unused Publisher"; + manifest.Installers.emplace_back().AppsAndFeaturesEntries.emplace_back().DisplayName = "Other Installed Name"; + manifest.CurrentLocalization.Add("Current Name"); + + std::vector expected{ u8"caf\u00E9" }; + if (defaultNameState == "Present") + { + expected.emplace_back("default name"); + } + expected.insert(expected.end(), { "installed name", "localized name", "other installed name" }); + REQUIRE(manifest.GetPackageNames() == expected); + + std::vector expectedOriginal{ u8"Caf\u00E9" }; + if (defaultNameState == "Present") + { + expectedOriginal.emplace_back("Default Name"); + } + expectedOriginal.insert(expectedOriginal.end(), { "Installed Name", "Localized Name", "Other Installed Name", "localized name" }); + REQUIRE(manifest.GetOriginalPackageNames() == expectedOriginal); +} + +TEST_CASE("Manifest_NameAndPublisherPairs", "[ManifestValidation]") +{ + std::string_view defaultNameState = GENERATE("Missing", "Empty", "Present"); + std::string_view defaultPublisherState = GENERATE("Missing", "Empty", "Present"); + bool emptyLocalizationValues = GENERATE(false, true); + CAPTURE(defaultNameState, defaultPublisherState, emptyLocalizationValues); + Manifest manifest; + const std::string defaultName = defaultNameState == "Present" ? "Default Name" : ""; + const std::string defaultPublisher = defaultPublisherState == "Present" ? "Default Publisher" : ""; + if (defaultNameState != "Missing") + { + manifest.DefaultLocalization.Add(defaultName); + } + if (defaultPublisherState != "Missing") + { + manifest.DefaultLocalization.Add(defaultPublisher); + } + auto& localization = manifest.Localizations.emplace_back(); + localization.Add("Localized Name"); + localization.Add("Localized Publisher"); + auto& nameOnly = manifest.Localizations.emplace_back(); + nameOnly.Add("Name Only"); + if (emptyLocalizationValues) + { + nameOnly.Add(""); + } + auto& publisherOnly = manifest.Localizations.emplace_back(); + publisherOnly.Add("Publisher Only"); + if (emptyLocalizationValues) + { + publisherOnly.Add(""); + } + auto& emptyLocalization = manifest.Localizations.emplace_back(); + if (emptyLocalizationValues) + { + emptyLocalization.Add(""); + emptyLocalization.Add(""); + } + auto& installer = manifest.Installers.emplace_back(); + auto& entry = installer.AppsAndFeaturesEntries.emplace_back(); + entry.DisplayName = "Installed Name"; + entry.Publisher = "Installed Publisher"; + installer.AppsAndFeaturesEntries.emplace_back().DisplayName = "Fallback Name"; + installer.AppsAndFeaturesEntries.emplace_back().Publisher = "Unused Publisher"; + + std::vector> expected; + if (!defaultName.empty()) + { + expected.emplace_back(defaultName, defaultPublisher); + } + expected.emplace_back("Localized Name", "Localized Publisher"); + expected.emplace_back("Name Only", defaultPublisher); + if (!defaultName.empty()) + { + expected.emplace_back(defaultName, "Publisher Only"); + } + expected.emplace_back("Installed Name", "Installed Publisher"); + expected.emplace_back("Fallback Name", defaultPublisher); + REQUIRE(manifest.GetNameAndPublisherPairs() == expected); +} + TEST_CASE("ReadPreviewGoodManifestAndVerifyContents", "[ManifestValidation]") { auto manifestFile = TestDataFile("Manifest-Good.yaml"); diff --git a/src/AppInstallerCommonCore/Manifest/Manifest.cpp b/src/AppInstallerCommonCore/Manifest/Manifest.cpp index 32612b1147..cc97003f87 100644 --- a/src/AppInstallerCommonCore/Manifest/Manifest.cpp +++ b/src/AppInstallerCommonCore/Manifest/Manifest.cpp @@ -162,10 +162,33 @@ namespace AppInstaller::Manifest { std::set set; - AddFoldedStringToSetIfNotEmpty(set, DefaultLocalization.Get()); + for (const auto& name : GetOriginalPackageNames()) + { + AddFoldedStringToSetIfNotEmpty(set, name); + } + + std::vector result( + std::make_move_iterator(set.begin()), + std::make_move_iterator(set.end())); + + return result; + } + + std::vector Manifest::GetOriginalPackageNames() const + { + std::set set; + auto addName = [&](const string_t& name) + { + if (!name.empty()) + { + set.emplace(name); + } + }; + + addName(DefaultLocalization.Get()); for (const auto& loc : Localizations) { - AddFoldedStringToSetIfNotEmpty(set, loc.Get()); + addName(loc.Get()); } // In addition to the names used for our display, add the display names from the ARP entries @@ -173,7 +196,7 @@ namespace AppInstaller::Manifest { for (const auto& appsAndFeaturesEntry : installer.AppsAndFeaturesEntries) { - AddFoldedStringToSetIfNotEmpty(set, appsAndFeaturesEntry.DisplayName); + addName(appsAndFeaturesEntry.DisplayName); } } @@ -210,6 +233,43 @@ namespace AppInstaller::Manifest return result; } + std::vector> Manifest::GetNameAndPublisherPairs() const + { + std::vector> result; + const auto defaultName = DefaultLocalization.Get(); + const auto defaultPublisher = DefaultLocalization.Get(); + if (!defaultName.empty()) + { + result.emplace_back(defaultName, defaultPublisher); + } + for (const auto& localization : Localizations) + { + const auto localizedName = localization.Get(); + const auto localizedPublisher = localization.Get(); + if (!localizedName.empty() || !localizedPublisher.empty()) + { + const auto& name = localizedName.empty() ? defaultName : localizedName; + const auto& publisher = localizedPublisher.empty() ? defaultPublisher : localizedPublisher; + if (!name.empty()) + { + result.emplace_back(name, publisher); + } + } + } + + for (const auto& installer : Installers) + { + for (const auto& entry : installer.AppsAndFeaturesEntries) + { + if (!entry.DisplayName.empty()) + { + result.emplace_back(entry.DisplayName, entry.Publisher.empty() ? defaultPublisher : entry.Publisher); + } + } + } + return result; + } + std::vector Manifest::GetSystemReferenceStrings( std::function extractStringFromInstaller, std::function extractStringFromAppsAndFeaturesEntry) const diff --git a/src/AppInstallerCommonCore/Public/winget/Manifest.h b/src/AppInstallerCommonCore/Public/winget/Manifest.h index 725b4dd56c..eecc166f24 100644 --- a/src/AppInstallerCommonCore/Public/winget/Manifest.h +++ b/src/AppInstallerCommonCore/Public/winget/Manifest.h @@ -7,6 +7,7 @@ #include #include +#include #include namespace AppInstaller::Manifest @@ -61,9 +62,15 @@ namespace AppInstaller::Manifest // Get package names across localizations and installers, Case folded. std::vector GetPackageNames() const; + // Get unique, non-empty package names across localizations and installers, preserving case. + std::vector GetOriginalPackageNames() const; + // Get publishers across localizations and installers, Case folded. std::vector GetPublishers() const; + // Gets name/publisher pairs with non-empty names and default fallbacks, preserving case. + std::vector> GetNameAndPublisherPairs() const; + // If not empty, the SHA256 hash of the manifest stream itself. Utility::SHA256::HashBuffer StreamSha256; diff --git a/src/AppInstallerRepositoryCore/ARPCorrelation.cpp b/src/AppInstallerRepositoryCore/ARPCorrelation.cpp index 871a932d07..17ab5b3c73 100644 --- a/src/AppInstallerRepositoryCore/ARPCorrelation.cpp +++ b/src/AppInstallerRepositoryCore/ARPCorrelation.cpp @@ -165,27 +165,9 @@ namespace AppInstaller::Repository::Correlation // Also attempt to find the entry based on the manifest data SearchRequest manifestSearchRequest; - AppInstaller::Manifest::Manifest::string_t defaultPublisher; - if (manifest.DefaultLocalization.Contains(Localization::Publisher)) + for (const auto& [name, publisher] : manifest.GetNameAndPublisherPairs()) { - defaultPublisher = manifest.DefaultLocalization.Get(); - } - - // The default localization must contain the name or we cannot do this lookup - if (manifest.DefaultLocalization.Contains(Localization::PackageName)) - { - AppInstaller::Manifest::Manifest::string_t defaultName = manifest.DefaultLocalization.Get(); - manifestSearchRequest.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, defaultName, defaultPublisher)); - - for (const auto& loc : manifest.Localizations) - { - if (loc.Contains(Localization::PackageName) || loc.Contains(Localization::Publisher)) - { - manifestSearchRequest.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, - loc.Contains(Localization::PackageName) ? loc.Get() : defaultName, - loc.Contains(Localization::Publisher) ? loc.Get() : defaultPublisher)); - } - } + manifestSearchRequest.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, name, publisher)); } std::set productCodes; @@ -203,13 +185,6 @@ namespace AppInstaller::Repository::Correlation for (const auto& appsAndFeaturesEntry : installer.AppsAndFeaturesEntries) { - if (!appsAndFeaturesEntry.DisplayName.empty()) - { - manifestSearchRequest.Inclusions.emplace_back(PackageMatchFilter(PackageMatchField::NormalizedNameAndPublisher, MatchType::Exact, - appsAndFeaturesEntry.DisplayName, - appsAndFeaturesEntry.Publisher.empty() ? defaultPublisher : appsAndFeaturesEntry.Publisher)); - } - // Add each ProductCode and UpgradeCode only once; if (!appsAndFeaturesEntry.ProductCode.empty() && productCodes.insert(appsAndFeaturesEntry.ProductCode).second) { diff --git a/src/AppInstallerRepositoryCore/CompositeSource.cpp b/src/AppInstallerRepositoryCore/CompositeSource.cpp index 9b47c95107..f6aa16bdeb 100644 --- a/src/AppInstallerRepositoryCore/CompositeSource.cpp +++ b/src/AppInstallerRepositoryCore/CompositeSource.cpp @@ -969,15 +969,12 @@ namespace AppInstaller::Repository { AddIfNotPresent(SystemReferenceString{ PackageMatchField::UpgradeCode, Utility::LocIndString{ upgradeCode } }); } - for (const auto& name : manifest.GetPackageNames()) + for (auto&& [name, publisher] : manifest.GetNameAndPublisherPairs()) { - for (const auto& publisher : manifest.GetPublishers()) - { - AddIfNotPresent(SystemReferenceString{ - PackageMatchField::NormalizedNameAndPublisher, - Utility::LocIndString{ name }, - Utility::LocIndString{ publisher } }); - } + AddIfNotPresent(SystemReferenceString{ + PackageMatchField::NormalizedNameAndPublisher, + Utility::LocIndString{ std::move(name) }, + Utility::LocIndString{ std::move(publisher) } }); } } @@ -996,21 +993,13 @@ namespace AppInstaller::Repository void GetNameAndPublisher( IPackage* package) { - // Unfortunately the names and publishers are unique and not tied to each other strictly, so we need - // to go broad on the matches. Future work can hopefully make name and publisher operate more as a unit, - // but for now we have to search for the cartesian of these... - auto names = package->GetMultiProperty(PackageMultiProperty::NormalizedName); - auto publishers = package->GetMultiProperty(PackageMultiProperty::NormalizedPublisher); - - for (const auto& name : names) + for (auto&& row : package->GetMatrixProperty(PackageMatrixProperty::NormalizedNameAndPublisher)) { - for (const auto& publisher : publishers) - { - AddIfNotPresent(SystemReferenceString{ - PackageMatchField::NormalizedNameAndPublisher, - name, - publisher }); - } + THROW_HR_IF(E_UNEXPECTED, row.size() != 2); + AddIfNotPresent(SystemReferenceString{ + PackageMatchField::NormalizedNameAndPublisher, + Utility::LocIndString{ std::move(row[0]) }, + Utility::LocIndString{ std::move(row[1]) } }); } } }; diff --git a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp index 46d7fd72b5..69e65ed327 100644 --- a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp +++ b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.cpp @@ -9,11 +9,6 @@ namespace AppInstaller::Repository { using ValueMatchFunction = bool (*)(const Utility::NormalizedString&, const Utility::NormalizedString&); - bool ValueMatchFunction_AlwaysFalse(const Utility::NormalizedString&, const Utility::NormalizedString&) - { - return false; - } - bool ValueMatchFunction_Exact(const Utility::NormalizedString& a, const Utility::NormalizedString& b) { return a == b; @@ -50,7 +45,7 @@ namespace AppInstaller::Repository case MatchType::FuzzySubstring: case MatchType::Wildcard: default: - return ValueMatchFunction_AlwaysFalse; + return nullptr; } } @@ -105,7 +100,7 @@ namespace AppInstaller::Repository auto matchFunction = GetMatchTypeFunction(matchType); - if (matchFunction(value, request.Value)) + if (matchFunction && matchFunction(value, request.Value)) { return matchType; } @@ -167,6 +162,170 @@ namespace AppInstaller::Repository return MatchType::Exact == result.Type; } + + // Keep definitive results; new metadata may answer previously unknown fields. + std::optional EvaluateRequest(const SearchRequest& request, + const std::function(const PackageMatchFilter&)>& matchesAvailableField, + std::vector>& filterMatches, + std::vector>& inclusionMatches, + bool& selectionMatches) + { + bool allFiltersMatch = true; + for (size_t i = 0; i < request.Filters.size(); ++i) + { + auto& match = filterMatches[i]; + if (!match.has_value()) + { + match = matchesAvailableField(request.Filters[i]); + } + if (match == false) + { + return false; + } + if (!match.has_value()) + { + allFiltersMatch = false; + } + } + + selectionMatches = !request.Query && request.Inclusions.empty(); + bool selectionUnknown = request.Query.has_value(); + for (size_t i = 0; i < request.Inclusions.size(); ++i) + { + auto& match = inclusionMatches[i]; + if (!match.has_value()) + { + match = matchesAvailableField(request.Inclusions[i]); + } + if (match == true) + { + selectionMatches = true; + break; + } + if (!match.has_value()) + { + selectionUnknown = true; + } + } + + if (!selectionMatches && !selectionUnknown) + { + return false; + } + if (allFiltersMatch && selectionMatches) + { + return true; + } + return std::nullopt; + } + } + + std::optional MatchesRequest(const RequestMatch& request, const Utility::NormalizedString& value) + { + if (auto matchFunction = GetMatchTypeFunction(request.Type)) + { + return matchFunction(value, request.Value); + } + + return std::nullopt; + } + + std::optional MatchesRequest(const PackageMatchFilter& request, const Utility::NormalizedString& value) + { + if (request.Type == MatchType::Exact && + (request.Field == PackageMatchField::PackageFamilyName || request.Field == PackageMatchField::ProductCode || + request.Field == PackageMatchField::UpgradeCode)) + { + return ValueMatchFunction_CaseInsensitive(value, request.Value); + } + + return MatchesRequest(static_cast(request), value); + } + + std::optional MatchesRequest(const PackageMatchFilter& request, const Manifest::Manifest& manifest) + { + if (!GetMatchTypeFunction(request.Type)) + { + return std::nullopt; + } + + auto matches = [&](const Utility::NormalizedString& value) + { + return !value.empty() && MatchesRequest(request, value).value_or(false); + }; + auto matchesAny = [&](const auto& values) + { + return std::any_of(values.begin(), values.end(), matches); + }; + + switch (request.Field) + { + case PackageMatchField::Id: + return matches(manifest.Id); + case PackageMatchField::Name: + return matchesAny(manifest.GetOriginalPackageNames()); + case PackageMatchField::Moniker: + return matches(manifest.Moniker); + case PackageMatchField::Tag: + return matchesAny(manifest.GetAggregatedTags()); + case PackageMatchField::Command: + return matchesAny(manifest.GetAggregatedCommands()); + case PackageMatchField::PackageFamilyName: + return matchesAny(manifest.GetPackageFamilyNames()); + case PackageMatchField::ProductCode: + return matchesAny(manifest.GetProductCodes()); + case PackageMatchField::UpgradeCode: + return matchesAny(manifest.GetUpgradeCodes()); + default: + return std::nullopt; + } + } + + std::optional MatchesRequest(const SearchRequest& request, + const std::function(const PackageMatchFilter&)>& matchesAvailableField, + const std::function(const PackageMatchFilter&)>& resolveUnknownField) + { + std::vector> filterMatches(request.Filters.size()); + std::vector> inclusionMatches(request.Inclusions.size()); + bool selectionMatches = false; + + auto result = EvaluateRequest(request, matchesAvailableField, filterMatches, inclusionMatches, selectionMatches); + if (result.has_value() || !resolveUnknownField) + { + return result; + } + + // Resolve each unknown at most once, checking all available metadata after each attempt. + for (size_t i = 0; i < request.Filters.size(); ++i) + { + if (!filterMatches[i].has_value()) + { + filterMatches[i] = resolveUnknownField(request.Filters[i]); + result = EvaluateRequest(request, matchesAvailableField, filterMatches, inclusionMatches, selectionMatches); + if (result.has_value()) + { + return result; + } + } + } + + // A source-defined query can still match even if every inclusion fails. + if (!request.Query && !selectionMatches) + { + for (size_t i = 0; i < request.Inclusions.size(); ++i) + { + if (!inclusionMatches[i].has_value()) + { + inclusionMatches[i] = resolveUnknownField(request.Inclusions[i]); + result = EvaluateRequest(request, matchesAvailableField, filterMatches, inclusionMatches, selectionMatches); + if (result.has_value() || selectionMatches) + { + return result; + } + } + } + } + return std::nullopt; } PackageMatchFilter FindBestMatchCriteria(const SearchRequest& request, const IPackageVersion* packageVersion) diff --git a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h index 6ad225275f..19b02d49cd 100644 --- a/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h +++ b/src/AppInstallerRepositoryCore/MatchCriteriaResolver.h @@ -2,9 +2,24 @@ // Licensed under the MIT License. #pragma once #include "Public/winget/RepositorySearch.h" +#include namespace AppInstaller::Repository { + // Returns whether the value matches, or nullopt if the match type cannot be evaluated locally. + std::optional MatchesRequest(const RequestMatch& request, const Utility::NormalizedString& value); + + // Applies field-specific casing rules when comparing a value. + std::optional MatchesRequest(const PackageMatchFilter& request, const Utility::NormalizedString& value); + + // Manifest matching leaves unsupported fields (including NormalizedNameAndPublisher) and match types unknown. + std::optional MatchesRequest(const PackageMatchFilter& request, const Manifest::Manifest& manifest); + + // Evaluates (Query OR Inclusions) AND Filters; source-defined queries remain unknown. + std::optional MatchesRequest(const SearchRequest& request, + const std::function(const PackageMatchFilter&)>& matchesAvailableField, + const std::function(const PackageMatchFilter&)>& resolveUnknownField = {}); + // Finds the highest rated match criteria for the package based on the search request, PackageMatchFilter FindBestMatchCriteria(const SearchRequest& request, const IPackageVersion* packageVersion); } diff --git a/src/AppInstallerRepositoryCore/Public/winget/RepositorySearch.h b/src/AppInstallerRepositoryCore/Public/winget/RepositorySearch.h index 2a1bff82a1..8ac190e7fe 100644 --- a/src/AppInstallerRepositoryCore/Public/winget/RepositorySearch.h +++ b/src/AppInstallerRepositoryCore/Public/winget/RepositorySearch.h @@ -12,6 +12,7 @@ #include #include #include +#include #include @@ -307,6 +308,13 @@ namespace AppInstaller::Repository // Maps the package multi-property value to its package version multi-property value for internal use. PackageVersionMultiProperty PackageMultiPropertyToPackageVersionMultiProperty(PackageMultiProperty property); + // A property of a package that contains rows of related values. + enum class PackageMatrixProperty + { + // Each row contains exactly two values: name, then publisher. + NormalizedNameAndPublisher, + }; + // To allow for runtime casting from IPackage to the specific types, this enum contains all of the IPackage implementations. enum class IPackageType { @@ -346,6 +354,9 @@ namespace AppInstaller::Repository // Gets a property of this package that can have multiple values. virtual std::vector GetMultiProperty(PackageMultiProperty property) const = 0; + // Gets rows of related values with columns defined by the matrix property. + virtual std::vector> GetMatrixProperty(PackageMatrixProperty property) const; + // Gets the source that this package is from. virtual Source GetSource() const = 0; diff --git a/src/AppInstallerRepositoryCore/RepositorySearch.cpp b/src/AppInstallerRepositoryCore/RepositorySearch.cpp index 358600a9d1..d2166e734d 100644 --- a/src/AppInstallerRepositoryCore/RepositorySearch.cpp +++ b/src/AppInstallerRepositoryCore/RepositorySearch.cpp @@ -123,6 +123,29 @@ namespace AppInstaller::Repository } } + std::vector> IPackage::GetMatrixProperty(PackageMatrixProperty property) const + { + switch (property) + { + case PackageMatrixProperty::NormalizedNameAndPublisher: + { + auto names = GetMultiProperty(PackageMultiProperty::NormalizedName); + auto publishers = GetMultiProperty(PackageMultiProperty::NormalizedPublisher); + std::vector> result; + for (const auto& name : names) + { + for (const auto& publisher : publishers) + { + result.push_back({ name.get(), publisher.get() }); + } + } + return result; + } + default: + THROW_HR(E_UNEXPECTED); + } + } + const char* UnsupportedRequestException::what() const noexcept { if (m_whatMessage.empty()) diff --git a/src/AppInstallerRepositoryCore/Rest/RestSource.cpp b/src/AppInstallerRepositoryCore/Rest/RestSource.cpp index ebfcde708b..6606cff198 100644 --- a/src/AppInstallerRepositoryCore/Rest/RestSource.cpp +++ b/src/AppInstallerRepositoryCore/Rest/RestSource.cpp @@ -57,6 +57,8 @@ namespace AppInstaller::Repository::Rest std::vector GetMultiProperty(PackageMultiProperty property) const override; + std::vector> GetMatrixProperty(PackageMatrixProperty property) const override; + std::vector GetVersionKeys() const override { std::shared_ptr source = GetReferenceSource(); @@ -144,6 +146,7 @@ namespace AppInstaller::Repository::Rest if (result.Matches.size() == 1) { + m_package.SearchVersions = std::move(m_package.Versions); m_package.Versions = std::move(result.Matches[0].Versions); SortVersionsInternal(); } @@ -269,25 +272,55 @@ namespace AppInstaller::Repository::Rest std::scoped_lock versionsLock{ m_packageVersionsLock }; std::vector result; PackageVersionMultiProperty mappedProperty = PackageMultiPropertyToPackageVersionMultiProperty(property); + auto addValue = [](std::vector& values, Utility::LocIndString&& string) + { + auto itr = std::lower_bound(values.begin(), values.end(), string); + + if (itr == values.end() || *itr != string) + { + values.emplace(itr, std::move(string)); + } + }; + + for (const auto& versionInfo : m_package.SearchVersions) + { + GetMultiPropertyValues( + this, versionInfo, mappedProperty, result, addValue); + } for (const auto& versionInfo : m_package.Versions) { GetMultiPropertyValues( - this, - versionInfo, - mappedProperty, - result, - [](std::vector& result, Utility::LocIndString&& string) - { - auto itr = std::lower_bound(result.begin(), result.end(), string); + this, versionInfo, mappedProperty, result, addValue); + } + + return result; + } - if (itr == result.end() || *itr != string) + std::vector> RestPackage::GetMatrixProperty(PackageMatrixProperty property) const + { + if (property != PackageMatrixProperty::NormalizedNameAndPublisher) + { + return IPackage::GetMatrixProperty(property); + } + + std::scoped_lock versionsLock{ m_packageVersionsLock }; + std::vector> result; + result.push_back({ m_package.PackageInformation.PackageName, m_package.PackageInformation.Publisher }); + for (const auto& version : m_package.Versions) + { + if (version.Manifest) + { + for (auto&& [name, publisher] : version.Manifest->GetNameAndPublisherPairs()) + { + std::vector row{ std::move(name), std::move(publisher) }; + if (std::find(result.begin(), result.end(), row) == result.end()) { - result.emplace(itr, std::move(string)); + result.emplace_back(std::move(row)); } - }); + } + } } - return result; } diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Interface.h b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Interface.h index 12e77136b5..725f1d7c3a 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Interface.h +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Interface.h @@ -31,8 +31,8 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 // Check query params against source information and update if necessary. virtual std::map GetValidatedQueryParams(const std::map& params) const; - // Check search request against source information and get json search body. - virtual web::json::value GetValidatedSearchBody(const SearchRequest& searchRequest) const; + // Check search request against source information and update if necessary. + virtual SearchRequest GetValidatedSearchRequest(const SearchRequest& searchRequest) const; virtual SearchResult GetSearchResult(const web::json::value& searchResponseObject) const; virtual std::vector GetParsedManifests(const web::json::value& manifestsResponseObject) const; @@ -43,6 +43,10 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 Http::HttpClientHelper::HttpRequestHeaders m_requiredRestApiHeaders; private: + // Returns whether manifest retrieval was deferred for a supported criterion. + bool FilterSearchResult(const SearchRequest& request, SearchResult& result, bool allowManifestRetrieval) const; + std::vector GetManifestsInternal(const std::string& packageId, const std::map& validatedParams) const; + std::string m_restApiUri; utility::string_t m_searchEndpoint; Http::HttpClientHelper m_httpClientHelper; diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Json/SearchRequestSerializer_1_0.cpp b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Json/SearchRequestSerializer_1_0.cpp index a44ce132c2..29b6e7144f 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Json/SearchRequestSerializer_1_0.cpp +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/Json/SearchRequestSerializer_1_0.cpp @@ -158,6 +158,7 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0::Json std::optional SearchRequestSerializer::GetRequestMatchJsonObject(const AppInstaller::Repository::RequestMatch& requestMatch) const { + // This schema has no field for Additional; normalized-pair requests send the name without the publisher. web::json::value match = web::json::value::object(); match[JSON::GetUtilityString(KeyWord)] = web::json::value::string(JSON::GetUtilityString(requestMatch.Value)); diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp index 5a9fb399e7..5b9ef51e9a 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_0/RestInterface_1_0.cpp @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. #include "pch.h" +#include "MatchCriteriaResolver.h" #include "Rest/Schema/1_0/Interface.h" #include "Rest/Schema/IRestClient.h" #include @@ -21,6 +22,158 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 // Query params constexpr std::string_view VersionQueryParam = "Version"sv; constexpr std::string_view ChannelQueryParam = "Channel"sv; + constexpr std::string_view MarketQueryParam = "Market"sv; + + bool CanResolveWithManifest(const PackageMatchFilter& filter) + { + switch (filter.Type) + { + case MatchType::Exact: + case MatchType::CaseInsensitive: + case MatchType::StartsWith: + case MatchType::Substring: + break; + default: + return false; + } + + switch (filter.Field) + { + case PackageMatchField::Name: + case PackageMatchField::Moniker: + case PackageMatchField::Tag: + case PackageMatchField::Command: + case PackageMatchField::PackageFamilyName: + case PackageMatchField::ProductCode: + case PackageMatchField::UpgradeCode: + return true; + default: + return false; + } + } + + std::optional MatchesPackage(const PackageMatchFilter& filter, const IRestClient::Package& package) + { + if (filter.Field == PackageMatchField::Id) + { + return MatchesRequest(filter, package.PackageInformation.PackageIdentifier); + } + if (filter.Field == PackageMatchField::Name && + MatchesRequest(filter, package.PackageInformation.PackageName).value_or(false)) + { + return true; + } + + std::optional result = package.Versions.empty() ? std::nullopt : std::optional{ false }; + for (const auto& version : package.Versions) + { + const std::vector* values = nullptr; + switch (filter.Field) + { + case PackageMatchField::PackageFamilyName: + values = &version.PackageFamilyNames; + break; + case PackageMatchField::ProductCode: + values = &version.ProductCodes; + break; + case PackageMatchField::UpgradeCode: + values = &version.UpgradeCodes; + break; + } + + if (values && std::any_of(values->begin(), values->end(), [&](const auto& value) + { + return !value.empty() && MatchesRequest(filter, value).value_or(false); + })) + { + return true; + } + + auto match = version.Manifest ? MatchesRequest(filter, version.Manifest.value()) : std::nullopt; + if (match && match.value()) + { + return true; + } + if (!match) + { + result = std::nullopt; + } + } + + return result; + } + + std::vector CreateVersionInfos(std::vector manifests) + { + std::vector versions; + versions.reserve(manifests.size()); + for (auto& manifest : manifests) + { + auto packageFamilyNames = manifest.GetPackageFamilyNames(); + auto productCodes = manifest.GetProductCodes(); + auto arpVersionRange = manifest.GetArpVersionRange(); + auto upgradeCodes = manifest.GetUpgradeCodes(); + AppInstaller::Utility::VersionAndChannel versionAndChannel{ manifest.Version, manifest.Channel }; + + versions.emplace_back( + IRestClient::VersionInfo{ + std::move(versionAndChannel), + std::move(manifest), + std::vector{ packageFamilyNames.begin(), packageFamilyNames.end() }, + std::vector{ productCodes.begin(), productCodes.end() }, + arpVersionRange.IsEmpty() ? std::vector{} : std::vector{ arpVersionRange.GetMinVersion(), arpVersionRange.GetMaxVersion() }, + std::vector{ upgradeCodes.begin(), upgradeCodes.end() } }); + } + + return versions; + } + + std::optional FindManifestByVersionAndChannel( + const std::vector& manifests, std::string_view version, std::string_view channel) + { + for (const auto& manifest : manifests) + { + if (Utility::CaseInsensitiveEquals(manifest.Version, version) && + Utility::CaseInsensitiveEquals(manifest.Channel, channel)) + { + return manifest; + } + } + + return std::nullopt; + } + + void PopulateManifestCache(IRestClient::Package& package, std::vector manifests) + { + Utility::NormalizedString packageIdentifier = package.PackageInformation.PackageIdentifier; + for (const auto& manifest : manifests) + { + if (!Utility::ICUCaseInsensitiveEquals(manifest.Id, packageIdentifier)) + { + AICLI_LOG(Repo, Error, << "Manifest response identifier '" << manifest.Id << + "' does not match '" << package.PackageInformation.PackageIdentifier << "'."); + THROW_HR(APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + } + + if (!manifests.empty() && package.Versions.size() == 1 && + package.Versions[0].VersionAndChannel.GetVersion().IsUnknown()) + { + package.SearchVersions = std::move(package.Versions); + package.Versions = CreateVersionInfos(std::move(manifests)); + } + else + { + for (auto& version : package.Versions) + { + if (!version.Manifest) + { + version.Manifest = FindManifestByVersionAndChannel(manifests, + version.VersionAndChannel.GetVersion().ToString(), version.VersionAndChannel.GetChannel().ToString()); + } + } + } + } utility::string_t GetSearchEndpoint(const std::string& restApiUri) { @@ -117,39 +270,77 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 IRestClient::SearchResult Interface::SearchInternal(const SearchRequest& request) const { + SearchRequest validatedRequest = GetValidatedSearchRequest(request); + if (!validatedRequest.Query && !request.Inclusions.empty() && validatedRequest.Inclusions.empty()) + { + AICLI_LOG(Repo, Info, << "No supported inclusions remain in the search request."); + return {}; + } + + constexpr size_t c_manifestRetrievalResultLimit = 3; + // Probe beyond the enrichment threshold even when the caller requests fewer results. + if (validatedRequest.Purpose == SearchPurpose::Default && validatedRequest.MaximumResults && + (std::any_of(validatedRequest.Filters.begin(), validatedRequest.Filters.end(), CanResolveWithManifest) || + (!validatedRequest.Query && std::any_of(validatedRequest.Inclusions.begin(), validatedRequest.Inclusions.end(), CanResolveWithManifest)))) + { + validatedRequest.MaximumResults = std::max(validatedRequest.MaximumResults, c_manifestRetrievalResultLimit + 1); + } + + const auto searchBody = SearchRequestComposer{ GetVersion() }.Serialize(validatedRequest); + size_t candidateCount = 0; + bool manifestRetrievalDeferred = false; SearchResult results; utility::string_t continuationToken; + std::set usedContinuationTokens; Http::HttpClientHelper::HttpRequestHeaders searchHeaders = m_requiredRestApiHeaders; do { if (!continuationToken.empty()) { + if (!usedContinuationTokens.emplace(continuationToken).second) + { + AICLI_LOG(Repo, Error, << "REST source returned a repeated continuation token."); + THROW_HR(APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + AICLI_LOG(Repo, Verbose, << "Received continuation token. Retrieving more results."); searchHeaders.insert_or_assign(AppInstaller::JSON::GetUtilityString(ContinuationToken), continuationToken); } - std::optional jsonObject = m_httpClientHelper.HandlePost(m_searchEndpoint, GetValidatedSearchBody(request), searchHeaders, GetAuthHeaders(), CustomRestCallResponseHandler); + std::optional jsonObject = m_httpClientHelper.HandlePost(m_searchEndpoint, searchBody, searchHeaders, GetAuthHeaders(), CustomRestCallResponseHandler); utility::string_t ct; if (jsonObject) { SearchResult currentResult = GetSearchResult(jsonObject.value()); - - size_t insertElements = !request.MaximumResults ? currentResult.Matches.size() : - std::min(currentResult.Matches.size(), request.MaximumResults - results.Matches.size()); - - if (insertElements < currentResult.Matches.size()) - { - results.Truncated = true; - } - - std::move(currentResult.Matches.begin(), std::next(currentResult.Matches.begin(), insertElements), std::inserter(results.Matches, results.Matches.end())); + candidateCount += currentResult.Matches.size(); + manifestRetrievalDeferred |= FilterSearchResult(validatedRequest, currentResult, false); + std::move(currentResult.Matches.begin(), currentResult.Matches.end(), std::inserter(results.Matches, results.Matches.end())); ct = GetContinuationToken(jsonObject.value()).value_or(L""); } continuationToken = ct; - } while (!continuationToken.empty() && (!request.MaximumResults || results.Matches.size() < request.MaximumResults)); + } while (!continuationToken.empty() && (!request.MaximumResults || results.Matches.size() < request.MaximumResults || + (manifestRetrievalDeferred && candidateCount <= c_manifestRetrievalResultLimit))); + + if (manifestRetrievalDeferred) + { + if (continuationToken.empty() && candidateCount <= c_manifestRetrievalResultLimit) + { + FilterSearchResult(validatedRequest, results, true); + } + else + { + AICLI_LOG(Repo, Verbose, << "Skipping manifest retrieval because the search exceeds the complete-result limit."); + } + } + + if (request.MaximumResults && results.Matches.size() > request.MaximumResults) + { + results.Matches.erase(std::next(results.Matches.begin(), request.MaximumResults), results.Matches.end()); + results.Truncated = true; + } if (!continuationToken.empty()) { @@ -164,6 +355,87 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 return results; } + bool Interface::FilterSearchResult(const SearchRequest& request, SearchResult& result, bool allowManifestRetrieval) const + { + bool manifestRetrievalDeferred = false; + std::vector matches; + matches.reserve(result.Matches.size()); + for (auto& package : result.Matches) + { + bool retrievalAttempted = false; + auto matchesField = [&](const PackageMatchFilter& filter) + { + return MatchesPackage(filter, package); + }; + + std::function(const PackageMatchFilter&)> resolveField; + if (request.Purpose == SearchPurpose::Default) + { + resolveField = [&](const PackageMatchFilter& filter) -> std::optional + { + if (!CanResolveWithManifest(filter)) + { + return std::nullopt; + } + + if (!allowManifestRetrieval) + { + manifestRetrievalDeferred = true; + return std::nullopt; + } + + if (!retrievalAttempted) + { + retrievalAttempted = true; + // Use the search market rather than defaulting the manifest lookup to the OS region. + std::map queryParams; + for (const auto& requestFilter : request.Filters) + { + if (requestFilter.Field == PackageMatchField::Market) + { + auto [market, inserted] = queryParams.emplace(MarketQueryParam, requestFilter.Value); + if ((requestFilter.Type != MatchType::Exact && requestFilter.Type != MatchType::CaseInsensitive) || + (!inserted && !Utility::ICUCaseInsensitiveEquals(market->second, requestFilter.Value))) + { + AICLI_LOG(Repo, Info, << "Manifest lookup cannot represent the requested market filters."); + return std::nullopt; + } + } + } + try + { + queryParams = GetValidatedQueryParams(queryParams); + } + catch (const UnsupportedRequestException& e) + { + AICLI_LOG(Repo, Info, << "Manifest lookup cannot validate search metadata for " << + package.PackageInformation.PackageIdentifier << ": " << e.what()); + return std::nullopt; + } + + AICLI_LOG(Repo, Verbose, << "Retrieving manifests to validate search criteria for " << package.PackageInformation.PackageIdentifier); + auto manifests = GetManifestsInternal(package.PackageInformation.PackageIdentifier, queryParams); + PopulateManifestCache(package, std::move(manifests)); + } + + return matchesField(filter); + }; + } + + // Only proven mismatches are removed; retained NormalizedNameAndPublisher candidates are unvalidated. + auto match = MatchesRequest(request, matchesField, resolveField); + if (match && !match.value()) + { + AICLI_LOG(Repo, Verbose, << "Discarding REST package " << package.PackageInformation.PackageIdentifier << + ": does not match search request " << request.ToString()); + continue; + } + matches.emplace_back(std::move(package)); + } + result.Matches = std::move(matches); + return manifestRetrievalDeferred; + } + std::optional Interface::GetManifestByVersion(const std::string& packageId, const std::string& version, const std::string& channel) const { std::map queryParams; @@ -177,21 +449,7 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 queryParams.emplace(ChannelQueryParam, channel); } - std::vector manifests = GetManifests(packageId, queryParams); - - if (!manifests.empty()) - { - for (Manifest::Manifest manifest : manifests) - { - if (Utility::CaseInsensitiveEquals(manifest.Version, version) && - Utility::CaseInsensitiveEquals(manifest.Channel, channel)) - { - return manifest; - } - } - } - - return {}; + return FindManifestByVersionAndChannel(GetManifests(packageId, queryParams), version, channel); } bool Interface::MeetsOptimizedSearchCriteria(const SearchRequest& request, bool allowSubstringMatch) const @@ -217,36 +475,25 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 IRestClient::SearchResult Interface::OptimizedSearch(const SearchRequest& request) const { SearchResult searchResult; - std::vector manifests = GetManifests(request.Filters[0].Value); + const auto& idFilter = request.Filters[0]; + std::vector manifests = GetManifests(idFilter.Value); if (!manifests.empty()) { auto& manifest = manifests.at(0); + if (MatchesRequest(idFilter, manifest.Id) == false) + { + AICLI_LOG(Repo, Error, << "Manifest response identifier '" << manifest.Id << + "' does not match search request " << request.ToString()); + THROW_HR(APPINSTALLER_CLI_ERROR_RESTSOURCE_INVALID_DATA); + } + PackageInfo packageInfo = PackageInfo{ manifest.Id, manifest.DefaultLocalization.Get(), manifest.DefaultLocalization.Get() }; - // Add all the versions to the package info object - std::vector versions; - for (auto& manifestVersion : manifests) - { - auto packageFamilyNames = manifestVersion.GetPackageFamilyNames(); - auto productCodes = manifestVersion.GetProductCodes(); - auto arpVersionRange = manifestVersion.GetArpVersionRange(); - auto upgradeCodes = manifestVersion.GetUpgradeCodes(); - - versions.emplace_back( - VersionInfo{ - AppInstaller::Utility::VersionAndChannel {manifestVersion.Version, manifestVersion.Channel}, - manifestVersion, - std::vector{ packageFamilyNames.begin(), packageFamilyNames.end()}, - std::vector{ productCodes.begin(), productCodes.end()}, - arpVersionRange.IsEmpty() ? std::vector{} : std::vector{ arpVersionRange.GetMinVersion(), arpVersionRange.GetMaxVersion() }, - std::vector{ upgradeCodes.begin(), upgradeCodes.end()} }); - } - - Package package = Package{ std::move(packageInfo), std::move(versions) }; + Package package = Package{ std::move(packageInfo), CreateVersionInfos(std::move(manifests)) }; searchResult.Matches.emplace_back(std::move(package)); } @@ -255,8 +502,11 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 std::vector Interface::GetManifests(const std::string& packageId, const std::map& params) const { - auto validatedParams = GetValidatedQueryParams(params); + return GetManifestsInternal(packageId, GetValidatedQueryParams(params)); + } + std::vector Interface::GetManifestsInternal(const std::string& packageId, const std::map& validatedParams) const + { std::vector results; utility::string_t continuationToken; Http::HttpClientHelper::HttpRequestHeaders searchHeaders = m_requiredRestApiHeaders; @@ -300,10 +550,9 @@ namespace AppInstaller::Repository::Rest::Schema::V1_0 return params; } - web::json::value Interface::GetValidatedSearchBody(const SearchRequest& searchRequest) const + SearchRequest Interface::GetValidatedSearchRequest(const SearchRequest& searchRequest) const { - SearchRequestComposer searchRequestComposer{ GetVersion() }; - return searchRequestComposer.Serialize(searchRequest); + return searchRequest; } IRestClient::SearchResult Interface::GetSearchResult(const web::json::value& searchResponseObject) const diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_1/Interface.h b/src/AppInstallerRepositoryCore/Rest/Schema/1_1/Interface.h index bd070c0543..b7ed879081 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_1/Interface.h +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_1/Interface.h @@ -23,8 +23,8 @@ namespace AppInstaller::Repository::Rest::Schema::V1_1 // Check query params against source information and update if necessary. std::map GetValidatedQueryParams(const std::map& params) const override; - // Check search request against source information and get json search body. - web::json::value GetValidatedSearchBody(const SearchRequest& searchRequest) const override; + // Check search request against source information and update if necessary. + SearchRequest GetValidatedSearchRequest(const SearchRequest& searchRequest) const override; SearchResult GetSearchResult(const web::json::value& searchResponseObject) const override; std::vector GetParsedManifests(const web::json::value& manifestsResponseObject) const override; diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/1_1/RestInterface_1_1.cpp b/src/AppInstallerRepositoryCore/Rest/Schema/1_1/RestInterface_1_1.cpp index 9bc0644c68..fc582419be 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/1_1/RestInterface_1_1.cpp +++ b/src/AppInstallerRepositoryCore/Rest/Schema/1_1/RestInterface_1_1.cpp @@ -78,7 +78,7 @@ namespace AppInstaller::Repository::Rest::Schema::V1_1 return result; } - web::json::value Interface::GetValidatedSearchBody(const SearchRequest& searchRequest) const + SearchRequest Interface::GetValidatedSearchRequest(const SearchRequest& searchRequest) const { SearchRequest resultSearchRequest = searchRequest; @@ -126,7 +126,7 @@ namespace AppInstaller::Repository::Rest::Schema::V1_1 } } - return V1_0::Interface::GetValidatedSearchBody(resultSearchRequest); + return V1_0::Interface::GetValidatedSearchRequest(resultSearchRequest); } IRestClient::SearchResult Interface::GetSearchResult(const web::json::value& searchResponseObject) const diff --git a/src/AppInstallerRepositoryCore/Rest/Schema/IRestClient.h b/src/AppInstallerRepositoryCore/Rest/Schema/IRestClient.h index ac248c9453..42760ad864 100644 --- a/src/AppInstallerRepositoryCore/Rest/Schema/IRestClient.h +++ b/src/AppInstallerRepositoryCore/Rest/Schema/IRestClient.h @@ -45,6 +45,8 @@ namespace AppInstaller::Repository::Rest::Schema { PackageInfo PackageInformation; std::vector Versions; + // Aggregate search metadata retained when an unknown version is expanded. + std::vector SearchVersions; Package(PackageInfo packageInfo, std::vector versions) : PackageInformation(std::move(packageInfo)), Versions(std::move(versions)) {} diff --git a/src/AppInstallerSharedLib/AppInstallerStrings.cpp b/src/AppInstallerSharedLib/AppInstallerStrings.cpp index f1ee1b359b..e5dc5163e1 100644 --- a/src/AppInstallerSharedLib/AppInstallerStrings.cpp +++ b/src/AppInstallerSharedLib/AppInstallerStrings.cpp @@ -217,7 +217,9 @@ namespace AppInstaller::Utility bool ICUCaseInsensitiveStartsWith(std::string_view a, std::string_view b) { - return a.length() >= b.length() && ICUCaseInsensitiveEquals(a.substr(0, b.length()), b); + const auto foldedA = FoldCase(a); + const auto foldedB = FoldCase(b); + return foldedA.compare(0, foldedB.size(), foldedB) == 0; } std::string ConvertToUTF8(std::wstring_view input)