Public Access
Merge branch 'claude/main-page-group-hierarchy-3a3210'
This commit is contained in:
@@ -299,7 +299,7 @@ because-string used as prose.
|
||||
`Sync.Tests/HostCipherTests.cs`. There is no shared group factory and with five new fields there should be.
|
||||
- Three guard tests fail by design and that is what they are for:
|
||||
`HostSecretMergeTests.EveryScalarField_IsRoutedThroughAMerge` (note `GroupId` is missing from its `with`
|
||||
block today — an existing gap worth closing while there),
|
||||
block today — an existing gap worth closing while there) *(closed: `GroupId` is in that block now)*,
|
||||
`ValueSemanticsTests.TryValidate_RejectsWhatCannotBeStored`, and the two "groups are flat" remarks.
|
||||
- `ItemKindsTests.ThePullFilterNamesEveryTypeThisBuildSynchronises` is an ordered list — add Tag in registry
|
||||
order. `AadResourceTypeTests` needs `PinnedPairs`, a `SealSample` arm and a `NewTag()` builder.
|
||||
@@ -307,7 +307,9 @@ because-string used as prose.
|
||||
with a reason containing "flat". Putting the parent **inside the payload** does not break it mechanically,
|
||||
but its stated reason becomes wrong. It should survive with a rewritten comment — the parent lives in the
|
||||
payload, the plaintext column stays refused, and ADR 0004 is why. Decide that deliberately rather than
|
||||
letting it drift.
|
||||
letting it drift. *(Decided that way, and the name says it now:
|
||||
`SyncEndpointTests.AHostGroupCarryingAPlaintextParent_IsRejected`. The column is still refused and the
|
||||
reason is the trust model rather than flatness.)*
|
||||
- `Push_AnUnsupportedEntityType_IsInvalidNotAFailedBatch` picks the first type with no kind registered, very
|
||||
likely Tag today. It self-heals to `HostTag` via `Assert.SkipWhen`.
|
||||
- `App.Layout.Tests` measures pixels and bends rather than breaks. Extend the seeders — nesting and a tag row
|
||||
@@ -323,6 +325,15 @@ because-string used as prose.
|
||||
`ConflictStore.Record` throws `ArgumentOutOfRangeException` on a conflict for any of those three. Pre-existing,
|
||||
unrelated to any of this, and `Tag` is already in that switch. Worth a separate fix.
|
||||
|
||||
> **Fixed 2026-08-04**, having outlived the phases that shipped the logs and the buckets — which is exactly
|
||||
> the drift a note like this is meant to prevent, so it is worth saying what let it last. Of the three callers
|
||||
> of `AadResourceTypes.For`, two reach it only when an item carries plaintext fields and none of these three
|
||||
> does; the third, `ConflictStore.RecordAsync`, calls it unconditionally but is reached only by a real merge
|
||||
> conflict, which every existing test raised against a `Host`. The arms are in, and two tests now hold them
|
||||
> there: `CacheStoreTests.AConflict_CanBeRecordedForEveryKindOfItem` records one per kind, and
|
||||
> `AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping` fails on the *next* item type added
|
||||
> without one, by name rather than by a list kept by hand.
|
||||
|
||||
## Prose that becomes false
|
||||
|
||||
Not a tidy-up — these are the places the codebase currently tells a user or a maintainer that this feature is
|
||||
|
||||
+16
-1
@@ -333,7 +333,11 @@ that appear rarely and matter most. Those are marked **◆**.
|
||||
- **Status bar** — the selected terminal's live dot and address, the vault's last status sentence, the sync
|
||||
label again, and the search hint.
|
||||
- **Nav rail** — eight destinations: `HOSTS FILES KEYS PINS SNIPS LOGS TEAM PREFS`. Five characters is a
|
||||
desktop constraint, not a product one; the phone can use words.
|
||||
desktop constraint, not a product one; the phone can use words. ⚠️ **Corrected:** the desktop's v3 moved
|
||||
FILES and the S3 buckets out of the rail and onto fixed tabs beside the terminals, leaving seven — they are
|
||||
the two destinations you *stay in* while something runs, and a rail entry is for somewhere you go and come
|
||||
back from. The phone reached the same count by a different route: three bottom-bar destinations with the
|
||||
rest behind Settings.
|
||||
- **Terminal strip** — always visible, above every screen. Tabs with a close cross *inside* each tab, a `+`,
|
||||
and a sentence when there are none. This is what makes a terminal a surface the window switches to rather
|
||||
than a screen you navigate away from, and it is the single most desktop-shaped idea in the product.
|
||||
@@ -341,10 +345,21 @@ that appear rarely and matter most. Those are marked **◆**.
|
||||
|
||||
### The nine destinations
|
||||
|
||||
> ⚠️ **This is the desktop as it stood when the audit was taken, and it is left that way on purpose** — it is
|
||||
> the checklist the scope was decided against, so rewriting it would erase what was actually weighed. Two
|
||||
> things have moved since, and both are noted where they belong below: the rail lost FILES and S3 to fixed
|
||||
> tabs in the desktop's v3, and the hosts sidebar became a grid of cards. Neither changed what a phone needs
|
||||
> somewhere to put, which is what this list is for.
|
||||
|
||||
**1. Hosts** — the list of machines, and what is known about the selected one.
|
||||
|
||||
- *Sidebar:* filter box; group headings with a chevron and a count, **shown only when groups exist**; host
|
||||
rows carrying a connected dot, name, sync badge, address and one word for how it authenticates.
|
||||
⚠️ **Corrected:** the desktop replaced this column with a grid of cards and a right-hand drawer, and its
|
||||
grid holds one level of the groups at a time — so the headings, their chevrons and UNGROUPED are now the
|
||||
*phone's* alone, drawn from `SidebarRows`. That is what shipped here, and it is why this head flattens the
|
||||
whole tree rather than navigating into it: there is no room for a row of group cards at 360dp and nothing
|
||||
to open one into. See `docs/design-import-gaps.md` on the desktop's v3.
|
||||
- *Editor* (doubles as "add"): name, hostname, port, username, notes, one authentication picker covering
|
||||
typed password / key / stored credential, a group picker, a relay checkbox with the sentence explaining
|
||||
that relay puts the address on the server in plain text, and **◆ forget host key** — the only way back from
|
||||
|
||||
+12
-1
@@ -300,7 +300,7 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
|
||||
|
||||
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
|
||||
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
|
||||
`13` HostCredential.
|
||||
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore.
|
||||
|
||||
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
|
||||
> `HostCredential` as syncable since the contract was frozen, but this table had no value for
|
||||
@@ -308,6 +308,17 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
|
||||
> implementation to need one would have had to invent a value or reuse a neighbour's. Append
|
||||
> only, and no such item has been stored.
|
||||
|
||||
> **Recorded 2026-08-04:** `14`, `15` and `16`. Unlike `12` and `13` these were not a hole — they
|
||||
> were added to `CryptoSpec.AadResourceType` when the two logs and the buckets shipped, and items
|
||||
> **have** been sealed under them. What was missing was this table, which had gone three item kinds
|
||||
> without being updated. Nothing changes on the wire or on disk; the normative list now says what
|
||||
> the code has been doing.
|
||||
>
|
||||
> **They are the first members that are not a fixed offset from their `SyncEntityType`
|
||||
> counterparts** — the two enums drifted apart when `12` and `13` closed the hole above, so
|
||||
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
|
||||
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
|
||||
|
||||
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
||||
|
||||
### 4.4 What this prevents
|
||||
|
||||
@@ -105,6 +105,13 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> double-pressing one opens it, and a breadcrumb trail above the cards is where you are and the way back —
|
||||
> the same trail the transfers screen draws over a directory.
|
||||
>
|
||||
> **And the grid holds one level of that trail rather than every host.** A host filed under a group is drawn
|
||||
> inside that group and not also on the screen the group's card sits on, which is what makes a card a place
|
||||
> instead of a label: while the level above held everything anyway, opening a group could only ever subtract.
|
||||
> The find box is the one thing that crosses a boundary — it searches the open group and everything under it,
|
||||
> so a machine can be found without first remembering where it was filed, and the chip on a card is what says
|
||||
> which group a result came out of. See `VaultViewModel.Matches`.
|
||||
>
|
||||
> **The type scale went up a point and the text ramp went white.** `#E3E7F4` was a blue-tinted white on
|
||||
> blue-black surfaces, which costs contrast twice — once for being darker than white and once for sharing
|
||||
> a hue with what it is drawn on. Every step of the ramp moved with the top, so the intervals the design
|
||||
@@ -298,9 +305,9 @@ caption buttons and window title drawn on top of the application's own — two s
|
||||
| Design element | Layer | What it would take | What ships instead |
|
||||
| --- | --- | --- | --- |
|
||||
| Tag chips (`nginx`, `eu`, `pg16`) | client-domain | A tag item type, and a way to put one on a host. | **Shipped.** `Tag = 5` is a live item kind and `HostSecret.TagIds` names them, merged per tag so two people tagging one host both keep theirs — which is what `HostTag = 6` was going to buy, so it stays reserved and unused. Chips are drawn on host rows on both heads and toggled in the host editor, which also creates one inline; renaming and deleting are a TAGS category on the keychain screen. The filter box still searches name, address and notes only — a chip is read rather than typed. |
|
||||
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. Nested in the data and drawn flat — a group carries a `ParentId` and the defaults its hosts inherit, and the sidebar still emits one heading per group in label order with no indentation. A keychain with no groups renders exactly as it did before — one flat list, no headings. |
|
||||
| Groups `PRODUCTION` / `STAGING` / `PERSONAL` | client-domain | A host-group item type (`HostGroup = 4`, reserved) or a group field on `HostSecret`. | **Shipped**, as both: `VaultHostGroup` is a synced item kind and `HostSecret.GroupId` names one. A group carries a `ParentId` and the defaults its hosts inherit, and the two heads draw the nesting differently on purpose. The desktop is a grid of cards holding one level at a time, the way a directory pane holds one directory; the phone has no room for a row of cards, so it flattens the whole tree into one heading per group in label order with no indentation. A keychain with no groups renders exactly as it did before on both — one flat list, no headings and no cards. |
|
||||
| Group badge `TEAM·PLATFORM` | server | **Built in M3.** | The vault's name on each row, and the personal vault ordered first. Not the team's name: two of a team's vaults would then carry the same badge and the badge would be naming the wrong thing. Distinct from the groups above, and deliberately so — a group is a shelf the user chose, a vault is who can read the item. |
|
||||
| Groups on a **team's** hosts | client-domain | A vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | **Half shipped, and the half that shipped had to.** Every readable vault's groups are now read into the resolution map, because a group lends a port, a username and a binding — so a host whose group went unread would silently dial 22 as nobody while the machine is on 2222 as `deploy`. A missing heading is cosmetic; a missing port is a connection to the wrong place. The editable list is still the active vault's alone, so a host a teammate filed still shows under UNGROUPED and cannot be renamed or unfiled from here. |
|
||||
| Groups on a **team's** hosts | client-domain | A vault id on each group row for rename and delete, and a way to tell two vaults' identically-named groups apart in a list with one heading per group. | **Half shipped, and the half that shipped had to.** Every readable vault's groups are now read into the resolution map, because a group lends a port, a username and a binding — so a host whose group went unread would silently dial 22 as nobody while the machine is on 2222 as `deploy`. A missing heading is cosmetic; a missing port is a connection to the wrong place. The editable list is still the active vault's alone, so a group a teammate made has no card and no heading and cannot be renamed from here. A host filed into one is drawn at the outermost level of the desktop's grid and under UNGROUPED on the phone — reachable either way, which is the point — with the chip on its card still naming the group, because the label is resolved through the wide map while the level is decided from the narrow list. |
|
||||
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
|
||||
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
|
||||
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
|
||||
|
||||
+38
-27
@@ -16,6 +16,12 @@ Three constraints put things on this list, and they are worth knowing before add
|
||||
|
||||
Each item says what to do, what a pass looks like, and what a failure would mean.
|
||||
|
||||
**On the numbering.** A check keeps its number for life, because code comments and other documents cite them
|
||||
— `HostGridTests` sends a reader to 7.6, `platform-flags.md` to 3.6–3.8. A check inserted later therefore
|
||||
takes a letter rather than pushing its neighbours along: 3.2a and 3.2b sit between 3.2 and 3.3 and always
|
||||
will. Add in the same way, and keep each one next to the check it belongs beside; a gap in the numbers means
|
||||
a phase had nothing left for a person to do, which is the good outcome rather than an omission.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — the shell and the tab strip
|
||||
@@ -290,11 +296,12 @@ surface. Use the ← button, or drag the file to the local pane first.
|
||||
|
||||
## Phase 3 — Host groups and snippets
|
||||
|
||||
Two synced item kinds, a sidebar that now draws headings, and one new frame between the host process and the
|
||||
renderer. The data half of all of that is covered: the payloads round-trip, the server refuses the plaintext
|
||||
fields, the sidebar's grouping and the snippet policy are in `ShellFlowTests`, and both new screens are
|
||||
measured. What is left here is the part that only exists inside a WebView, plus the two-machine cases no
|
||||
single-process test can reach.
|
||||
Two synced item kinds, two ways of drawing a group — the desktop's grid of cards, one level at a time, and
|
||||
the phone's flat list under headings — and one new frame between the host process and the renderer. The data
|
||||
half of all of that is covered: the payloads round-trip, the server refuses the plaintext fields, both ways
|
||||
of drawing a group and the snippet policy are in `ShellFlowTests`, and both new screens are measured. What is
|
||||
left here is the part that only exists inside a WebView, plus the two-machine cases no single-process test
|
||||
can reach.
|
||||
|
||||
### 3.1 A keychain with no groups looks exactly as it did
|
||||
|
||||
@@ -334,19 +341,6 @@ a filed host is worse than either: it is the box answering "no host matches that
|
||||
keychain has got. A full-width bar with a chevron between the cards is the old grouping coming back through
|
||||
`SidebarRows`; the desktop grid binds `VisibleHosts`. See `HostsScreen.axaml`.
|
||||
|
||||
### 3.2b Making something while standing inside a group
|
||||
|
||||
Open a group, then press **+ NEW HOST**, and afterwards **+ NEW GROUP**.
|
||||
|
||||
**Pass:** the host editor opens with that group already chosen in its group picker, and the saved host is on
|
||||
the screen it was made on rather than somewhere the trail is not. The group editor likewise opens with that
|
||||
group as its parent, so the new group is a card inside the one that is open.
|
||||
|
||||
**Failure means:** anything created inside a group disappearing the moment it is saved. That is the papercut
|
||||
a level-at-a-time grid comes with, and `NewHost` / `NewGroup` are where it is answered. Note the deliberate
|
||||
difference between them: the host editor also takes a merely *selected* card as its group, the group editor
|
||||
takes only the group that is open.
|
||||
|
||||
### 3.2a A group inside a group, and the way back out
|
||||
|
||||
Make two groups and file one under the other with the parent picker in the group editor.
|
||||
@@ -361,17 +355,18 @@ Pressing the **middle** crumb goes back one level rather than all the way out.
|
||||
the flat `Groups` is the phone's and the lookups'. A group that cannot be reached at all is worse and is the
|
||||
case `EffectiveParents` promotes: see 3.4a.
|
||||
|
||||
### 3.4a A parent that dangles, or a cycle · **needs two machines**
|
||||
### 3.2b Making something while standing inside a group
|
||||
|
||||
Rare, and the reason it is here is that the repair for both is inside the group's own editor, which is
|
||||
opened from its card. On A, file group X under group Y and sync. On B, sync, delete Y, sync. Back on A, sync.
|
||||
For the cycle: with both machines offline, file X under Y on A and Y under X on B, then sync both.
|
||||
Open a group, then press **+ NEW HOST**, and afterwards **+ NEW GROUP**.
|
||||
|
||||
**Pass:** in both cases every group still has a card at the outermost level, so every one of them can be
|
||||
opened and its parent cleared. Nothing is missing from the grid, and the trail never loops.
|
||||
**Pass:** the host editor opens with that group already chosen in its group picker, and the saved host is on
|
||||
the screen it was made on rather than somewhere the trail is not. The group editor likewise opens with that
|
||||
group as its parent, so the new group is a card inside the one that is open.
|
||||
|
||||
**Failure means:** a group drawn nowhere is a group that can never be repaired — the fix is locked inside the
|
||||
state that needs fixing. See `VaultViewModel.EffectiveParents`.
|
||||
**Failure means:** anything created inside a group disappearing the moment it is saved. That is the papercut
|
||||
a level-at-a-time grid comes with, and `NewHost` / `NewGroup` are where it is answered. Note the deliberate
|
||||
difference between them: the host editor also takes a merely *selected* card as its group, the group editor
|
||||
takes only the group that is open.
|
||||
|
||||
### 3.3 Deleting a group with hosts in it
|
||||
|
||||
@@ -394,6 +389,18 @@ the group picker shows "(a group that is no longer here)" and *keeps it selected
|
||||
**Failure means:** if the picker opened on "No group", saving has just unfiled the host — quietly, as a side
|
||||
effect of an unrelated edit. That is the case `BuildGroupChoices` adds the placeholder for.
|
||||
|
||||
### 3.4a A parent that dangles, or a cycle · **needs two machines**
|
||||
|
||||
Rare, and the reason it is here is that the repair for both is inside the group's own editor, which is
|
||||
opened from its card. On A, file group X under group Y and sync. On B, sync, delete Y, sync. Back on A, sync.
|
||||
For the cycle: with both machines offline, file X under Y on A and Y under X on B, then sync both.
|
||||
|
||||
**Pass:** in both cases every group still has a card at the outermost level, so every one of them can be
|
||||
opened and its parent cleared. Nothing is missing from the grid, and the trail never loops.
|
||||
|
||||
**Failure means:** a group drawn nowhere is a group that can never be repaired — the fix is locked inside the
|
||||
state that needs fixing. See `VaultViewModel.EffectiveParents`.
|
||||
|
||||
### 3.5 A grouped host stays editable on an older build · **needs two builds**
|
||||
|
||||
Only worth doing before a release that ships alongside an older client. A host filed into a group is written
|
||||
@@ -753,7 +760,7 @@ wheel. The pointer has to keep moving inside the band — a stationary pointer g
|
||||
**Failure means:** headless Avalonia cannot synthesise a platform drag, so the picking up, the cursor and
|
||||
the drop are covered by nothing. What *is* automated is the decision each drag event takes —
|
||||
`HostGridTests.TheGroupCardsAreWhatAcceptsADroppedHost` raises a real `DragOver` over both kinds of card —
|
||||
and the write at the end, `ShellFlowTests.MovingAHostToAGroup_FilesItAndLeavesItSelected`.
|
||||
and the write at the end, `ShellFlowTests.MovingAHostToAGroup_FilesItAndTakesItOffTheLevelItCameFrom`.
|
||||
|
||||
### 7.7 A click still selects, and a double click still connects
|
||||
|
||||
@@ -804,6 +811,8 @@ add a bucket — then come back.
|
||||
**Failure means:** the screen has gone back to copying the vault's lists once at unlock. Covered by
|
||||
`TheTransfersScreen_FollowsTheVaultsHostList`; this is the version of it with a real picker in front of it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — Adding hosts on the phone
|
||||
|
||||
Nine checks, and the reason there are nine rather than none is worth stating: **the layout suite cannot see
|
||||
@@ -902,6 +911,8 @@ Walk the sheet's two rows, the editor's fields, both pickers, the checkbox and t
|
||||
**Failure means:** a control took its size from its glyph. The desktop suite's only tap-target assertion has
|
||||
a floor of 20 pixels — set for a mouse — so copying it here would certify a target no thumb can hit.
|
||||
|
||||
---
|
||||
|
||||
## Phase 9 — Tag chips and the picker
|
||||
|
||||
The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one
|
||||
|
||||
@@ -98,10 +98,27 @@ public sealed class LocalCacheProtector : IDisposable
|
||||
/// Maps a syncable entity type onto the resource type its AAD binds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A switch rather than a cast, even though the two enums happen to be adjacent. They are not the
|
||||
/// same list: <see cref="CryptoSpec.AadResourceType"/> also covers users, devices and vaults, so the
|
||||
/// numbers do not line up, and a cast would bind an item's ciphertext to the wrong resource type
|
||||
/// without failing anywhere a test would notice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It has to name every syncable type, and the throw is not a safety net.</b>
|
||||
/// <c>ConflictStore.RecordAsync</c> calls this unconditionally, so a type with no arm here is a type
|
||||
/// whose <em>conflicts</em> cannot be recorded — and the conflict log is the only reason the merge is
|
||||
/// allowed to pick a winner at all. Three types went two phases without one: the two logs and the
|
||||
/// buckets were added to both enums and to the reconciler registry, and this switch was not touched,
|
||||
/// so a merge over any of them turned a recorded conflict into an
|
||||
/// <see cref="ArgumentOutOfRangeException"/>. The two other callers hid it —
|
||||
/// <c>ItemStore</c> and <c>OutboxStore</c> only reach this when an item carries plaintext fields, and
|
||||
/// none of those three does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>AadResourceTypeTests.EverySyncableType_HasAnArmInTheStorageMapping</c> is what says so now, and it
|
||||
/// is a name comparison rather than a list to keep by hand.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class AadResourceTypes
|
||||
{
|
||||
@@ -117,6 +134,14 @@ internal static class AadResourceTypes
|
||||
SyncEntityType.Snippet => CryptoSpec.AadResourceType.Snippet,
|
||||
SyncEntityType.PortForward => CryptoSpec.AadResourceType.PortForward,
|
||||
SyncEntityType.KnownHostKey => CryptoSpec.AadResourceType.KnownHostKey,
|
||||
|
||||
// Appended in the order the enums grew, which is why these three are not beside their
|
||||
// neighbours by number. See docs/crypto.md §4.3 on why 14–16 are not one-behind their wire
|
||||
// counterparts the way the arms above are.
|
||||
SyncEntityType.ConnectionLogEntry => CryptoSpec.AadResourceType.ConnectionLogEntry,
|
||||
SyncEntityType.ActivityLogEntry => CryptoSpec.AadResourceType.ActivityLogEntry,
|
||||
SyncEntityType.ObjectStore => CryptoSpec.AadResourceType.ObjectStore,
|
||||
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
|
||||
};
|
||||
|
||||
@@ -394,6 +394,49 @@ public sealed class CacheStoreTests : IAsyncLifetime
|
||||
all.Detail.ShouldBe(new byte[] { 1, 2, 3 });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A conflict can be recorded against any kind of item, not only the kinds that were here first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every other test in this section uses <see cref="SyncEntityType.Host"/>, and that is how three item
|
||||
/// kinds shipped with no way to record a conflict at all: the two logs and the buckets were added to both
|
||||
/// enums, to the reconciler registry and to the cipher pinning, while <c>AadResourceTypes.For</c> — which
|
||||
/// <c>ConflictStore.RecordAsync</c> calls unconditionally — kept throwing for them. The two other callers
|
||||
/// of that mapping only reach it when an item carries plaintext fields, which none of the three does, so
|
||||
/// nothing else so much as touched the gap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A theory over the types rather than one more <c>Host</c> case, because the failure was never about
|
||||
/// conflicts and always about which types the layer below had been taught. Recording is asserted through
|
||||
/// a read-back rather than by "it did not throw": an arm returning the wrong resource type would seal
|
||||
/// under one AAD and open under another, which is a null detail rather than an exception.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(SyncEntityType.Host)]
|
||||
[InlineData(SyncEntityType.HostGroup)]
|
||||
[InlineData(SyncEntityType.Snippet)]
|
||||
[InlineData(SyncEntityType.ConnectionLogEntry)]
|
||||
[InlineData(SyncEntityType.ActivityLogEntry)]
|
||||
[InlineData(SyncEntityType.ObjectStore)]
|
||||
public async Task AConflict_CanBeRecordedForEveryKindOfItem(SyncEntityType entityType)
|
||||
{
|
||||
var detail = System.Text.Encoding.UTF8.GetBytes($$"""{"kind":"{{entityType}}"}""");
|
||||
|
||||
var id = await harness.Conflicts.RecordAsync(
|
||||
VaultId, entityType, Guid.CreateVersion7(), ConflictKind.FieldOverridden, detail, Token);
|
||||
|
||||
var listed = (await harness.Conflicts.ListAsync(VaultId, false, Token)).ShouldHaveSingleItem();
|
||||
|
||||
listed.Id.ShouldBe(id);
|
||||
listed.EntityType.ShouldBe(entityType);
|
||||
listed.Detail.ShouldBe(
|
||||
detail,
|
||||
"an empty detail here means the record was sealed under one resource type and opened under "
|
||||
+ "another, which ListAsync reports as nothing rather than as a failure");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnacknowledgedConflict_CannotBeDiscarded()
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
@@ -151,6 +152,63 @@ public sealed class AadResourceTypeTests
|
||||
.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,
|
||||
|
||||
Reference in New Issue
Block a user