Public Access
AadResourceTypes.For maps a syncable type onto the AAD resource type its cache records bind to, and it had no arm for ConnectionLogEntry, ActivityLogEntry or ObjectStore. All three are on both enums, in the reconciler registry and in the cipher pinning; only this switch was missed, and it throws rather than falling back — so a merge conflict on a connection log, an activity log or a bucket raised ArgumentOutOfRangeException on the path that records what the merge discarded. The conflict log is the whole reason the merge is allowed to pick a winner, so the one item kind whose conflicts could not be recorded was a bucket: an editable item two machines can genuinely disagree about. Worth writing down why it lasted two phases. Of the three callers, ItemStore and OutboxStore reach the mapping only when an item carries plaintext fields, and none of these three kinds does — so they never touched the gap. ConflictStore calls it unconditionally, but a test only reaches that by causing a real merge conflict, and every existing one raised its conflict against a Host. Three arms missing, and no path in the suite crossed any of them. So the tests are the point of this commit as much as the arms are. The guard is AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping: it walks the whole wire enum, and for each type asserts both that there is an arm and that the arm returns the same-named resource type, which is the mistake the file's cipher half already guards against on the server side. Written over the full enum rather than over ItemKinds.SyncedTypes, because that is the stronger claim and the one the switch really makes — the two reserved association types have arms too. Beside it, CacheStoreTests.AConflict_CanBeRecordedForEveryKindOfItem records a conflict per kind and reads the detail back, since an arm returning the wrong resource type seals under one AAD and opens under another, which surfaces as an empty detail rather than as a throw. Both were confirmed to fail with the arms removed: the theory fails on exactly ConnectionLogEntry, ActivityLogEntry and ObjectStore and passes on the other three, and the guard names those three and no others. The note in docs/adding-hosts-on-the-phone.md that recorded this as out of scope is marked fixed, with what let it survive, since that is the part worth knowing next time an item kind is added. 1529 tests pass, seven of them new.
452 lines
19 KiB
C#
452 lines
19 KiB
C#
using System.Security.Cryptography;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Storage;
|
|
using DodoSSH.Client.Sync;
|
|
using DodoSSH.Contracts;
|
|
using DodoSSH.Crypto;
|
|
|
|
namespace DodoSSH.Client.Sync.Tests;
|
|
|
|
/// <summary>
|
|
/// Each item type must be sealed under its own AAD resource type, and the two enums that name item types
|
|
/// deliberately do not agree.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <c>SyncEntityType</c> lists only syncable items, so <c>Host</c> is 1 and <c>SshKey</c> is 3.
|
|
/// <c>CryptoSpec.AadResourceType</c> also covers users, devices and vaults, so the same two are 4 and 6. A
|
|
/// cipher written by copying its neighbour and casting the wire type would therefore seal a private key as
|
|
/// if it were a vault — encrypting cleanly, decrypting cleanly on the machine that wrote it, and violating
|
|
/// docs/crypto.md in a way that surfaces only when another implementation reads the item.
|
|
/// </para>
|
|
/// <para>
|
|
/// These tests are cheap and the alternative is a comment. The payload's AAD is frozen, so getting this
|
|
/// wrong is not something a later release can quietly correct: only clients can re-encrypt, and they can
|
|
/// only do it if they can still open what is there.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class AadResourceTypeTests
|
|
{
|
|
/// <remarks>
|
|
/// The pairing stated as a table. If <c>AadResourceType</c> is ever renumbered, this is what says so —
|
|
/// loudly, and before anything is written under the new numbers.
|
|
/// </remarks>
|
|
[Theory]
|
|
[InlineData(SyncEntityType.Host, CryptoSpec.AadResourceType.Host)]
|
|
[InlineData(SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential)]
|
|
[InlineData(SyncEntityType.SshKey, CryptoSpec.AadResourceType.SshKey)]
|
|
[InlineData(SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup)]
|
|
[InlineData(SyncEntityType.Tag, CryptoSpec.AadResourceType.Tag)]
|
|
[InlineData(SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet)]
|
|
[InlineData(SyncEntityType.PortForward, CryptoSpec.AadResourceType.PortForward)]
|
|
[InlineData(SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey)]
|
|
[InlineData(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry)]
|
|
[InlineData(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry)]
|
|
[InlineData(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore)]
|
|
public void TheTwoEnums_AreNamedAlikeAndNumberedDifferently(
|
|
SyncEntityType wire,
|
|
CryptoSpec.AadResourceType resource)
|
|
{
|
|
Enum.GetName(wire).ShouldBe(Enum.GetName(resource));
|
|
|
|
// The point of the whole file: same name, different number. A test asserting equality here would be
|
|
// asserting the bug.
|
|
((int)wire).ShouldNotBe(
|
|
(int)resource,
|
|
$"{wire} happens to share a value with its resource type, which makes a cast look correct. "
|
|
+ "Either the enums were renumbered or this pairing needs re-checking by hand.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// The specified pairing of wire type to AAD resource type, stated out of band, one row per cipher.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The single source for both tests below: what each cipher must use, and which types must have a cipher
|
|
/// pinned at all. Adding an item type without adding a row here fails
|
|
/// <see cref="EverySynchronisedType_HasItsCipherPinnedHere"/>.
|
|
/// </remarks>
|
|
private static readonly (SyncEntityType Wire, CryptoSpec.AadResourceType Resource)[] PinnedPairs =
|
|
[
|
|
(SyncEntityType.Host, CryptoSpec.AadResourceType.Host),
|
|
(SyncEntityType.SshKey, CryptoSpec.AadResourceType.SshKey),
|
|
(SyncEntityType.Credential, CryptoSpec.AadResourceType.Credential),
|
|
(SyncEntityType.KnownHostKey, CryptoSpec.AadResourceType.KnownHostKey),
|
|
(SyncEntityType.HostGroup, CryptoSpec.AadResourceType.HostGroup),
|
|
(SyncEntityType.Tag, CryptoSpec.AadResourceType.Tag),
|
|
(SyncEntityType.Snippet, CryptoSpec.AadResourceType.Snippet),
|
|
(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry),
|
|
(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry),
|
|
(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore),
|
|
];
|
|
|
|
public static TheoryData<SyncEntityType, CryptoSpec.AadResourceType> Pinned
|
|
{
|
|
get
|
|
{
|
|
var data = new TheoryData<SyncEntityType, CryptoSpec.AadResourceType>();
|
|
|
|
foreach (var (wire, resource) in PinnedPairs)
|
|
{
|
|
data.Add(wire, resource);
|
|
}
|
|
|
|
return data;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Opened <em>independently</em>, through the low-level <c>ItemKeys</c> API with the resource type this
|
|
/// table names rather than the one the cipher holds. That is the whole point, and it is the property two
|
|
/// earlier versions of this file lacked: checking that a key payload does not open as a host is true
|
|
/// however both ciphers are misconfigured, because <c>Seal</c> and <c>TryOpen</c> share one constant. A
|
|
/// test that compares an implementation against itself cannot catch a self-consistent mistake.
|
|
/// </para>
|
|
/// <para>
|
|
/// Written as a table over every cipher, not one test per cipher, because the same hole was found three
|
|
/// times — twice by mutation testing after the fact. Pointing <c>CredentialCipher</c> at
|
|
/// <c>AadResourceType.Vault</c> passed the entire suite until this existed.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Theory]
|
|
[MemberData(nameof(Pinned))]
|
|
public void EveryCipher_SealsUnderTheResourceTypeTheSpecificationNames(
|
|
SyncEntityType wire,
|
|
CryptoSpec.AadResourceType resource)
|
|
{
|
|
var vaultKey = RandomNumberGenerator.GetBytes(32);
|
|
|
|
var entityId = Guid.CreateVersion7();
|
|
const uint Generation = 1;
|
|
const uint Version = 1;
|
|
|
|
var payload = SealSample(wire, vaultKey, entityId, Generation, (int)Version);
|
|
|
|
var dataKey = ItemKeys.TryUnwrapDataKey(
|
|
vaultKey, payload.WrappedDataKey, resource, entityId, Generation, Version);
|
|
|
|
dataKey.ShouldNotBeNull(
|
|
$"The {wire} cipher must wrap its data key under AadResourceType.{resource}; a null here means "
|
|
+ "it used some other resource type, which round-trips fine and violates docs/crypto.md.");
|
|
|
|
ItemKeys.TryOpenPayload(
|
|
dataKey,
|
|
payload.Envelope,
|
|
resource,
|
|
entityId,
|
|
payload.DataKeyId,
|
|
Generation,
|
|
Version).ShouldNotBeNull("and it must seal the envelope under the same resource type.");
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The guard that makes the table above self-maintaining. A fourth item type would otherwise sync,
|
|
/// encrypt and merge correctly while being sealed under any resource type at all, and nothing would say
|
|
/// so until another implementation refused the item — by which point the AAD is frozen into stored
|
|
/// ciphertext and only clients can re-encrypt it.
|
|
/// </remarks>
|
|
[Fact]
|
|
public void EverySynchronisedType_HasItsCipherPinnedHere()
|
|
{
|
|
PinnedPairs.Select(pair => pair.Wire)
|
|
.ShouldBe(ItemKinds.SyncedTypes, ignoreOrder: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same pairing, made a second time in the storage layer, and every type must be in it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <c>AadResourceTypes.For</c> is the cache's copy of the table above: the ciphers seal an item for the
|
|
/// <em>server</em>, and this seals the two things the local cache holds in the clear — a relay host's
|
|
/// address, and the values a merge overrode. A type missing from it throws rather than mis-seals, which
|
|
/// sounds like the safe failure and is not: <c>ConflictStore.RecordAsync</c> calls it unconditionally, so
|
|
/// the exception lands on the path that records what a merge discarded.
|
|
/// </para>
|
|
/// <para>
|
|
/// This is written after finding three types missing from it — <c>ConnectionLogEntry</c>,
|
|
/// <c>ActivityLogEntry</c> and <c>ObjectStore</c> went two shipping phases without an arm, because the
|
|
/// only unconditional caller is one a test suite reaches solely by causing a real merge conflict. Asserted
|
|
/// over the whole wire enum rather than over <c>ItemKinds.SyncedTypes</c>, which is the stronger claim and
|
|
/// the one the switch actually makes: the two reserved association types have arms too.
|
|
/// </para>
|
|
/// </remarks>
|
|
[Fact]
|
|
public void EverySyncableType_HasAnArmInTheStorageMapping()
|
|
{
|
|
var missing = new List<SyncEntityType>();
|
|
|
|
foreach (var wire in Enum.GetValues<SyncEntityType>())
|
|
{
|
|
if (wire == SyncEntityType.Unspecified)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
CryptoSpec.AadResourceType resource;
|
|
|
|
try
|
|
{
|
|
resource = AadResourceTypes.For(wire);
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
missing.Add(wire);
|
|
continue;
|
|
}
|
|
|
|
// Same name, as the cipher table demands — a wrong-but-present arm is the failure this half
|
|
// would otherwise wave through.
|
|
Enum.GetName(resource).ShouldBe(
|
|
Enum.GetName(wire),
|
|
$"AadResourceTypes.For({wire}) returns {resource}, which binds this type's cache records "
|
|
+ "to another type's resource.");
|
|
}
|
|
|
|
missing.ShouldBeEmpty(
|
|
"every syncable type needs an arm in AadResourceTypes.For, or a conflict recorded against one "
|
|
+ "of these throws instead of being written — and the conflict log is what justifies the merge "
|
|
+ "picking a winner.");
|
|
}
|
|
|
|
private static EncryptedPayload SealSample(
|
|
SyncEntityType wire,
|
|
byte[] vaultKey,
|
|
Guid entityId,
|
|
uint generation,
|
|
int version) => wire switch
|
|
{
|
|
SyncEntityType.Host => HostCipher.Seal(
|
|
new HostSecret { Label = "prod-db", Hostname = "db.internal" },
|
|
vaultKey,
|
|
entityId,
|
|
generation,
|
|
version),
|
|
|
|
SyncEntityType.SshKey => SshKeyCipher.Seal(
|
|
NewKey(), vaultKey, entityId, generation, version),
|
|
|
|
SyncEntityType.Credential => CredentialCipher.Seal(
|
|
NewCredential(), vaultKey, entityId, generation, version),
|
|
|
|
SyncEntityType.KnownHostKey => KnownHostKeyCipher.Seal(
|
|
NewKnownHost(), vaultKey, entityId, generation, version),
|
|
|
|
SyncEntityType.HostGroup => HostGroupCipher.Seal(
|
|
NewGroup(), vaultKey, entityId, generation, version),
|
|
|
|
// Tag is the row this file was written for. It is 5 on the wire and 8 in the crypto enum, and 5
|
|
// in the crypto enum is Credential — so a TagCipher written by casting its wire type would seal
|
|
// every tag in the vault under the resource type for a password, and only this test would say so.
|
|
SyncEntityType.Tag => TagCipher.Seal(
|
|
NewTag(), vaultKey, entityId, generation, version),
|
|
|
|
SyncEntityType.Snippet => SnippetCipher.Seal(
|
|
NewSnippet(), vaultKey, entityId, generation, version),
|
|
|
|
SyncEntityType.ConnectionLogEntry => ConnectionLogCipher.Seal(
|
|
NewConnectionEntry(), vaultKey, entityId, generation, version),
|
|
|
|
SyncEntityType.ActivityLogEntry => ActivityLogCipher.Seal(
|
|
NewActivityEntry(), vaultKey, entityId, generation, version),
|
|
|
|
SyncEntityType.ObjectStore => ObjectStoreCipher.Seal(
|
|
NewBucket(), vaultKey, entityId, generation, version),
|
|
|
|
_ => throw new ArgumentOutOfRangeException(
|
|
nameof(wire),
|
|
wire,
|
|
"No sample exists for this item type. Add one when adding the type, or the pairing above "
|
|
+ "cannot be checked."),
|
|
};
|
|
|
|
[Fact]
|
|
public void ACredentialPayload_OpensAsNothingElse()
|
|
{
|
|
var vaultKey = RandomNumberGenerator.GetBytes(32);
|
|
|
|
var entityId = Guid.CreateVersion7();
|
|
|
|
var sealed_ = CredentialCipher.Seal(
|
|
NewCredential(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1);
|
|
|
|
HostCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
|
|
SshKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
|
|
CredentialCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void ACredentialSealedAtOneVersion_DoesNotOpenAtAnother()
|
|
{
|
|
var vaultKey = RandomNumberGenerator.GetBytes(32);
|
|
|
|
var entityId = Guid.CreateVersion7();
|
|
|
|
var payload = CredentialCipher.Seal(
|
|
NewCredential(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2);
|
|
|
|
CredentialCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void AKeyPayload_DoesNotOpenAsAHost()
|
|
{
|
|
// Weaker than the two above and kept anyway: it is the property a reader expects to see, and it
|
|
// covers the case where one cipher is corrected and the other is not.
|
|
var vaultKey = RandomNumberGenerator.GetBytes(32);
|
|
|
|
var entityId = Guid.CreateVersion7();
|
|
|
|
var sealedKey = SshKeyCipher.Seal(NewKey(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1);
|
|
|
|
HostCipher.TryOpen(sealedKey, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
|
|
SshKeyCipher.TryOpen(sealedKey, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void AHostPayload_DoesNotOpenAsAKey()
|
|
{
|
|
var vaultKey = RandomNumberGenerator.GetBytes(32);
|
|
|
|
var entityId = Guid.CreateVersion7();
|
|
|
|
var host = new HostSecret { Label = "prod-db", Hostname = "db.internal" };
|
|
var sealedHost = HostCipher.Seal(host, vaultKey, entityId, keyGeneration: 1, itemVersion: 1);
|
|
|
|
SshKeyCipher.TryOpen(sealedHost, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void AKey_RoundTripsThroughTheCipher()
|
|
{
|
|
var vaultKey = RandomNumberGenerator.GetBytes(32);
|
|
|
|
var entityId = Guid.CreateVersion7();
|
|
var key = NewKey();
|
|
|
|
var payload = SshKeyCipher.Seal(key, vaultKey, entityId, keyGeneration: 1, itemVersion: 3);
|
|
var opened = SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3);
|
|
|
|
opened.ShouldNotBeNull();
|
|
opened.Key.ShouldBe(key);
|
|
opened.SchemaVersion.ShouldBe(SshKeySecretCodec.CurrentSchemaVersion);
|
|
opened.IsReadOnly.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void AKeySealedAtOneVersion_DoesNotOpenAtAnother()
|
|
{
|
|
// The item version is in the AAD, which is what stops a server rolling a row back to earlier
|
|
// ciphertext. Asserted for keys as well as hosts because it is the property most easily lost by
|
|
// copying a cipher and adjusting the wrong argument.
|
|
var vaultKey = RandomNumberGenerator.GetBytes(32);
|
|
|
|
var entityId = Guid.CreateVersion7();
|
|
|
|
var payload = SshKeyCipher.Seal(NewKey(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2);
|
|
|
|
SshKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void AKnownHostPayload_OpensAsNothingElse()
|
|
{
|
|
// The pairing table above is the load-bearing check; this is the cross-type refusal a reader expects
|
|
// to see spelled out, and it is the one that would notice a second cipher being pointed at
|
|
// AadResourceType.KnownHostKey by mistake.
|
|
var vaultKey = RandomNumberGenerator.GetBytes(32);
|
|
|
|
var entityId = Guid.CreateVersion7();
|
|
|
|
var sealed_ = KnownHostKeyCipher.Seal(
|
|
NewKnownHost(), vaultKey, entityId, keyGeneration: 1, itemVersion: 1);
|
|
|
|
HostCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
|
|
SshKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
|
|
CredentialCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldBeNull();
|
|
KnownHostKeyCipher.TryOpen(sealed_, vaultKey, entityId, itemVersion: 1).ShouldNotBeNull();
|
|
}
|
|
|
|
[Fact]
|
|
public void AKnownHostSealedAtOneVersion_DoesNotOpenAtAnother()
|
|
{
|
|
var vaultKey = RandomNumberGenerator.GetBytes(32);
|
|
|
|
var entityId = Guid.CreateVersion7();
|
|
|
|
var payload = KnownHostKeyCipher.Seal(
|
|
NewKnownHost(), vaultKey, entityId, keyGeneration: 1, itemVersion: 2);
|
|
|
|
KnownHostKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion: 3).ShouldBeNull();
|
|
}
|
|
|
|
private static ConnectionLogSecret NewConnectionEntry() => new()
|
|
{
|
|
HostLabel = "prod-db",
|
|
Address = "deploy@db.internal:22",
|
|
HostId = Guid.CreateVersion7(),
|
|
StartedAt = new DateTimeOffset(2026, 7, 31, 9, 15, 0, TimeSpan.Zero),
|
|
Duration = TimeSpan.FromMinutes(11),
|
|
Outcome = ConnectionOutcome.Closed,
|
|
DeviceName = "laptop",
|
|
ActorUserId = Guid.CreateVersion7(),
|
|
};
|
|
|
|
private static ActivityLogSecret NewActivityEntry() => new()
|
|
{
|
|
ItemKind = nameof(SyncEntityType.Host),
|
|
ItemId = Guid.CreateVersion7(),
|
|
ItemLabel = "prod-db",
|
|
Operation = ActivityOperation.Updated,
|
|
ChangedFields = "Port, Username",
|
|
At = new DateTimeOffset(2026, 7, 31, 9, 15, 0, TimeSpan.Zero),
|
|
DeviceName = "laptop",
|
|
ActorUserId = Guid.CreateVersion7(),
|
|
};
|
|
|
|
private static ObjectStoreSecret NewBucket() => new()
|
|
{
|
|
Label = "backups",
|
|
Bucket = "dodossh-backups",
|
|
AccessKeyId = "AKIAEXAMPLE",
|
|
SecretAccessKey = "an example secret access key",
|
|
Region = "eu-west-1",
|
|
};
|
|
|
|
private static HostGroupSecret NewGroup() => new() { Label = "production" };
|
|
|
|
private static TagSecret NewTag() => new() { Label = "pci" };
|
|
|
|
private static SnippetSecret NewSnippet() => new()
|
|
{
|
|
Label = "restart the api",
|
|
Command = "sudo systemctl restart dodossh-api",
|
|
Notes = "checked with the on-call rota first",
|
|
};
|
|
|
|
private static KnownHostSecret NewKnownHost() => new()
|
|
{
|
|
Host = "db.internal",
|
|
Port = 22,
|
|
Algorithm = "ssh-ed25519",
|
|
Fingerprint = "SHA256:5cWZ1Zc2ZmEXAMPLEfingerprintvalue0123456789a",
|
|
};
|
|
|
|
private static CredentialSecret NewCredential() => new()
|
|
{
|
|
Label = "db-login",
|
|
Password = "hunter2",
|
|
Username = "postgres",
|
|
Notes = "used by CI",
|
|
};
|
|
|
|
private static SshKeySecret NewKey() => new()
|
|
{
|
|
Label = "deploy",
|
|
PrivateKeyPem = "-----BEGIN OPENSSH PRIVATE KEY-----\nnot-a-real-key\n-----END OPENSSH PRIVATE KEY-----",
|
|
Passphrase = "a passphrase",
|
|
PublicKey = "ssh-ed25519 AAAAC3Nz deploy@example",
|
|
Notes = "used by CI",
|
|
};
|
|
}
|