Skip to content

Commit 7563dbd

Browse files
authored
feat: hash api key in path and cleanup scoped account settings (#967)
* feat: store account settings without plain api key INT-1811 - hashes api key in path used to store (imported) myparcel account settings - cleans up orphaned account settings rows - also fixes the automatic import of account settings after changing an api key * fix: repair get time config * refactor: have scope coordinates more available Also trims inline documentation comments a bit * fix: remove some unneeded checks * fix: improve comments and add success log * fix: never overwrite a fresh row with legacy during migrate * feat: make the fingerprinting a migration * doc: add docblocks to public methods
1 parent 2081e91 commit 7563dbd

21 files changed

Lines changed: 1144 additions & 131 deletions

Controller/Adminhtml/Settings/CarrierConfigurationImport.php

Lines changed: 28 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,10 @@
99
use Magento\Framework\App\Cache\Frontend\Pool;
1010
use Magento\Framework\App\Cache\TypeListInterface;
1111
use Magento\Framework\App\Config\ScopeConfigInterface;
12-
use Magento\Framework\App\Config\Storage\WriterInterface;
1312
use Magento\Framework\Controller\Result\JsonFactory;
13+
use MyParcelNL\Magento\Service\AccountSettings\Importer;
14+
use MyParcelNL\Magento\Service\AccountSettings\Maintenance as AccountSettingsMaintenance;
1415
use MyParcelNL\Magento\Service\Config;
15-
use MyParcelNL\Sdk\Model\Account\CarrierOptions;
16-
use MyParcelNL\Sdk\Services\Web\AccountWebService;
17-
use MyParcelNL\Sdk\Services\Web\CarrierOptionsWebService;
18-
use MyParcelNL\Sdk\Support\Collection;
1916

2017
class CarrierConfigurationImport extends Action
2118
{
@@ -25,38 +22,41 @@ class CarrierConfigurationImport extends Action
2522
/**
2623
* @var mixed
2724
*/
28-
private $typeListInterface;
29-
private WriterInterface $configWriter;
25+
private $typeListInterface;
26+
private Importer $importer;
27+
private AccountSettingsMaintenance $accountSettingsMaintenance;
3028

3129
/**
32-
* @param \Magento\Framework\Controller\Result\JsonFactory $resultFactory
33-
* @param \Magento\Backend\App\Action\Context $context
34-
* @param \Magento\Framework\Model\ResourceModel\Db\Context $dbContext
35-
* @param \Magento\Framework\App\Cache\TypeListInterface $typeListInterface
36-
* @param \Magento\Framework\App\Config\ScopeConfigInterface $config
37-
* @param \Magento\Framework\App\Cache\Frontend\Pool $pool
30+
* @param Context $context
31+
* @param ScopeConfigInterface $config
32+
* @param JsonFactory $resultFactory
33+
* @param TypeListInterface $typeListInterface
34+
* @param Pool $pool
35+
* @param Importer $importer
36+
* @param AccountSettingsMaintenance $accountSettingsMaintenance
3837
*/
3938
public function __construct(
40-
Context $context,
41-
WriterInterface $configWriter,
42-
ScopeConfigInterface $config,
43-
JsonFactory $resultFactory,
44-
TypeListInterface $typeListInterface,
45-
Pool $pool
39+
Context $context,
40+
ScopeConfigInterface $config,
41+
JsonFactory $resultFactory,
42+
TypeListInterface $typeListInterface,
43+
Pool $pool,
44+
Importer $importer,
45+
AccountSettingsMaintenance $accountSettingsMaintenance
4646
)
4747
{
4848
parent::__construct($context);
4949
$params = $this->_request->getParams();
5050
$scope = $params['scope'] ?? ScopeConfigInterface::SCOPE_TYPE_DEFAULT;
5151
$scopeId = $params['scopeId'] ?? 0;
5252

53-
// Let’s save the carrier configuration settings per api key, so it can be retrieved per api key as well.
54-
$this->apiKey = $config->getValue(Config::XML_PATH_GENERAL . 'api/key', $scope, $scopeId);
53+
$this->apiKey = $config->getValue(Config::XML_PATH_API_KEY, $scope, $scopeId);
5554

56-
$this->configWriter = $configWriter;
57-
$this->resultFactory = $resultFactory;
58-
$this->typeListInterface = $typeListInterface;
59-
$this->pool = $pool;
55+
$this->resultFactory = $resultFactory;
56+
$this->typeListInterface = $typeListInterface;
57+
$this->pool = $pool;
58+
$this->importer = $importer;
59+
$this->accountSettingsMaintenance = $accountSettingsMaintenance;
6060
}
6161

6262
/**
@@ -66,15 +66,11 @@ public function __construct(
6666
*/
6767
public function execute()
6868
{
69-
$configuration = $this->fetchConfigurations();
70-
$this->configWriter->save(
71-
Config::XML_PATH_GENERAL . "account_settings_$this->apiKey",
72-
json_encode($this->createArray($configuration))
73-
);
69+
$this->importer->importFor($this->apiKey);
7470

75-
// Clear configuration cache right after saving the account settings, so the modal in the carrier specific
76-
// configuration view will be showing the updated drop-off point.
7771
$this->clearCache();
72+
// After the flush, because it reads config.
73+
$this->accountSettingsMaintenance->reconcile();
7874

7975
return $this->resultFactory->create()
8076
->setData(
@@ -86,33 +82,6 @@ public function execute()
8682
;
8783
}
8884

89-
/**
90-
* @return \MyParcelNL\Sdk\Support\Collection
91-
* @throws \MyParcelNL\Sdk\Exception\AccountNotActiveException
92-
* @throws \MyParcelNL\Sdk\Exception\ApiException
93-
* @throws \MyParcelNL\Sdk\Exception\MissingFieldException
94-
*/
95-
public function fetchConfigurations(): Collection
96-
{
97-
$accountService = (new AccountWebService())->setApiKey($this->apiKey);
98-
99-
$account = $accountService->getAccount();
100-
$shop = $account->getShops()
101-
->first()
102-
;
103-
$shopId = $shop->getId();
104-
$optionConfigurationService = (new CarrierOptionsWebService())->setApiKey($this->apiKey);
105-
$optionConfiguration = $optionConfigurationService->getCarrierOptions($shopId);
106-
107-
return new Collection(
108-
[
109-
'shop' => $shop,
110-
'account' => $account,
111-
'carrier_options' => $optionConfiguration,
112-
]
113-
);
114-
}
115-
11685
private function clearCache(): void
11786
{
11887
$cacheFrontendPool = $this->pool;
@@ -124,42 +93,4 @@ private function clearCache(): void
12493
;
12594
}
12695
}
127-
128-
/**
129-
* @param \MyParcelNL\Sdk\Support\Collection $settings
130-
*
131-
* @return array
132-
* @TODO sdk#326 remove this entire function and replace with toArray
133-
*/
134-
private function createArray(Collection $settings): array
135-
{
136-
/** @var \MyParcelNL\Sdk\Model\Account\Shop $shop */
137-
$shop = $settings->get('shop');
138-
/** @var \MyParcelNL\Sdk\Model\Account\Account $account */
139-
$account = $settings->get('account');
140-
/** @var \MyParcelNL\Sdk\Model\Account\CarrierOptions[]|Collection $carrierOptions */
141-
$carrierOptions = $settings->get('carrier_options');
142-
143-
return [
144-
'shop' => [
145-
'id' => $shop->getId(),
146-
'name' => $shop->getName(),
147-
],
148-
'account' => $account->toArray(),
149-
'carrier_options' => array_map(static function (CarrierOptions $carrierOptions) {
150-
$carrier = $carrierOptions->getCarrier();
151-
return [
152-
'carrier' => [
153-
'human' => $carrier->getHuman(),
154-
'id' => $carrier->getId(),
155-
'name' => $carrier->getName(),
156-
],
157-
'enabled' => $carrierOptions->isEnabled(),
158-
'label' => $carrierOptions->getLabel(),
159-
'optional' => $carrierOptions->isOptional(),
160-
'type' => $carrierOptions->getType(),
161-
];
162-
}, $carrierOptions->all()),
163-
];
164-
}
16596
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use MyParcelNL\Magento\Service\Config;
6+
use MyParcelNL\Magento\Service\Hash\Fingerprint;
7+
8+
/**
9+
* The config path an api key's account settings belong at. Uses the real Fingerprint, so a test
10+
* expressing an expectation cannot drift from the algorithm production code uses.
11+
*/
12+
function settingsPathFor(string $apiKey): string
13+
{
14+
return Config::XML_PATH_ACCOUNT_SETTINGS . (new Fingerprint())->of($apiKey);
15+
}
16+
17+
/**
18+
* The pre-fingerprint path, where the plaintext api key sat in the path itself. Only the rewrite
19+
* patch may still encounter one.
20+
*/
21+
function legacySettingsPathFor(string $apiKey): string
22+
{
23+
return Config::XML_PATH_ACCOUNT_SETTINGS . $apiKey;
24+
}

Tests/Helpers/MyParcelTokenLifecycleHarness.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Magento\Config\Model\ResourceModel\Config\Data\Collection as ConfigDataCollection;
88
use Magento\Config\Model\ResourceModel\Config\Data\CollectionFactory;
99
use Magento\Framework\App\Cache\TypeListInterface;
10+
use Magento\Framework\App\Config\ScopeConfigInterface;
1011
use Magento\Framework\App\Config\Storage\WriterInterface;
1112
use Magento\Framework\App\RequestInterface;
1213
use Magento\Framework\DataObject;
@@ -61,12 +62,33 @@ public function valueAt(string $scope, int $scopeId): ?string
6162
return null;
6263
}
6364

65+
/**
66+
* First row at this path, whatever scope it sits at. Account settings rows are unique by path,
67+
* so a caller asserting on one does not have to name the scope it expects.
68+
*
69+
* @return array{path: string, value: string, scope: string, scope_id: int}|null
70+
*/
71+
public function rowAt(string $path): ?array
72+
{
73+
foreach ($this->rows as $row) {
74+
if ($row['path'] === $path) {
75+
return $row;
76+
}
77+
}
78+
return null;
79+
}
80+
6481
public function writer(): WriterInterface
6582
{
6683
$store = $this;
6784
$writer = Mockery::mock(WriterInterface::class);
6885
$writer->shouldReceive('save')->andReturnUsing(
69-
function (string $path, string $value, string $scope, int $scopeId) use ($store): void {
86+
function (
87+
string $path,
88+
string $value,
89+
string $scope = ScopeConfigInterface::SCOPE_TYPE_DEFAULT,
90+
int $scopeId = 0
91+
) use ($store): void {
7092
$store->save($path, $value, $scope, $scopeId);
7193
}
7294
);
@@ -100,6 +122,8 @@ public function collectionFactory(): CollectionFactory
100122
$value = $row[$field] ?? null;
101123
if (is_array($cond) && array_key_exists('in', $cond)) {
102124
if (! in_array($value, $cond['in'], true)) { $ok = false; break; }
125+
} elseif (is_array($cond) && array_key_exists('like', $cond)) {
126+
if (! self::matchesSqlLike((string) $value, (string) $cond['like'])) { $ok = false; break; }
103127
} elseif ($value !== $cond) {
104128
$ok = false; break;
105129
}
@@ -117,6 +141,17 @@ public function collectionFactory(): CollectionFactory
117141
return $factory;
118142
}
119143

144+
/**
145+
* `_` matters as much as `%`: a prefix like `account_settings_` is full of underscores, so MySQL
146+
* matches more loosely than the pattern looks, and a double honouring only `%` would hide that.
147+
*/
148+
private static function matchesSqlLike(string $value, string $pattern): bool
149+
{
150+
$regex = str_replace(['%', '_'], ['.*', '.'], preg_quote($pattern, '/'));
151+
152+
return 1 === preg_match('/^' . $regex . '$/', $value);
153+
}
154+
120155
public function cacheTypeList(): TypeListInterface
121156
{
122157
return mockCacheTypeList();
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Magento\Framework\App\Config\ScopeConfigInterface;
6+
use Magento\Framework\App\Config\Storage\WriterInterface;
7+
use MyParcelNL\Magento\Service\AccountSettings\Importer;
8+
use MyParcelNL\Magento\Service\Config;
9+
use MyParcelNL\Magento\Service\Hash\Fingerprint;
10+
use Psr\Log\LoggerInterface;
11+
12+
/**
13+
* Only hasSettingsFor() is covered: the rest of Importer instantiates the SDK web services directly,
14+
* so it has no seam to test through.
15+
*
16+
* @param array<string, string> $rowsByPath
17+
*/
18+
function importerFor(array $rowsByPath): Importer
19+
{
20+
$scopeConfig = Mockery::mock(ScopeConfigInterface::class);
21+
$scopeConfig->shouldReceive('getValue')->andReturnUsing(
22+
static fn (string $path) => $rowsByPath[$path] ?? null
23+
);
24+
25+
return new Importer(
26+
Mockery::spy(WriterInterface::class),
27+
$scopeConfig,
28+
new Fingerprint(),
29+
Mockery::spy(LoggerInterface::class)
30+
);
31+
}
32+
33+
function accountSettingsPath(string $apiKey): string
34+
{
35+
return Config::XML_PATH_ACCOUNT_SETTINGS . (new Fingerprint())->of($apiKey);
36+
}
37+
38+
it('reports settings present when a row exists for the key', function () {
39+
$importer = importerFor([accountSettingsPath('live-key') => '{"shop":1}']);
40+
41+
expect($importer->hasSettingsFor('live-key'))->toBeTrue();
42+
});
43+
44+
it('reports settings absent when no row exists at all', function () {
45+
expect(importerFor([])->hasSettingsFor('live-key'))->toBeFalse();
46+
});
47+
48+
it('does not mistake another key\'s row for its own', function () {
49+
$importer = importerFor([accountSettingsPath('other-key') => '{"shop":9}']);
50+
51+
expect($importer->hasSettingsFor('live-key'))->toBeFalse();
52+
});
53+
54+
it('treats an empty stored value as absent', function () {
55+
$importer = importerFor([accountSettingsPath('live-key') => '']);
56+
57+
expect($importer->hasSettingsFor('live-key'))->toBeFalse();
58+
});

0 commit comments

Comments
 (0)