Public Access
2179 lines
116 KiB
Markdown
2179 lines
116 KiB
Markdown
# Things a person still has to check
|
||
|
||
Automated tests cover what they can reach. This file is the rest: the checks that need a real window, a
|
||
real network, a real remote host, or a real Explorer — and the reasons each one is out of reach.
|
||
|
||
Three constraints put things on this list, and they are worth knowing before adding to it:
|
||
|
||
- **`MainWindow` cannot be laid out by a test.** WebView2's adapter refuses the headless dispatcher's MTA
|
||
thread — see `LayoutHarnessTests.WhyTheWindowItselfIsNeverShown`. Anything that has to be measured lives
|
||
on a `UserControl` instead, and what is left in the window is unmeasured by construction.
|
||
- **Headless Avalonia has no native window.** So nothing about Win32 focus, about the WebView collapsing,
|
||
or about a drag that crosses into another application can be asserted. A headless test of any of those
|
||
would pass and confirm the wrong belief.
|
||
- **No network, no container, no remote host** in the ordinary suite. The SSH suites that do use one
|
||
(`DodoSSH.Client.Ssh.Tests`, `DodoSSH.SystemTests`) need Docker and are the exception.
|
||
|
||
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
|
||
|
||
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
|
||
|
||
Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Vaults,
|
||
Preferences — and both of the fixed tabs, SFTP and S3.
|
||
|
||
**Pass:** each screen draws whole, its buttons all clickable, and the tab strip stays across the top of all
|
||
nine. The nav rail is there for the seven and gone for the two, because it belongs to the Vaults tab.
|
||
|
||
**Failure means:** a screen is not collapsing while the terminal shows. The terminal is a native child
|
||
window and composites above everything Avalonia paints, so the symptom is a screen cut off at the WebView's
|
||
left edge with the rest unreachable. This window has shipped that defect once. The single wrapper `Panel`
|
||
bound to `IsShowingPages` in `MainWindow.axaml` is what should make it impossible.
|
||
|
||
### 1.2 A tab clicked from another screen takes the keyboard
|
||
|
||
Go to FILES with a terminal open. Click the tab. **Start typing immediately, without clicking anything
|
||
else.**
|
||
|
||
**Pass:** every character reaches the shell, including the first.
|
||
|
||
**Failure means:** `FocusTerminalWhenLaidOut` in `MainWindow.axaml.cs` is posting too early.
|
||
`NativeControlHost` re-pushes its bounds on the next layout pass, so focusing ahead of that pass races the
|
||
thing the focus depends on. The symptom is losing only the first keystroke or two, which is why this has to
|
||
be typed immediately rather than after a pause.
|
||
|
||
### 1.3 Leaving a terminal gives the keyboard back
|
||
|
||
With a terminal focused, click FILES. Type into the filter box.
|
||
|
||
**Pass:** the characters appear in the box.
|
||
|
||
**Failure means:** `ReleaseKeyboardTo` is missing on that path. **Collapsing the WebView does not release
|
||
the keyboard** — the native child window goes on holding Win32 focus and Avalonia then sees no key events
|
||
at all, so the screen that just appeared silently swallows everything. This was a latent bug before the
|
||
strip rework and is now on the hot path. See `docs/platform-flags.md`.
|
||
|
||
### 1.4 The middle click closes tabs and only tabs
|
||
|
||
Middle-click a tab (closes it), the strip background to the right of the last tab (closes nothing), and the
|
||
`+` button (closes nothing, opens nothing).
|
||
|
||
**Pass:** as described. Covered by `TerminalTabsTests` headlessly, so this is a confirmation that headless
|
||
pointer input matches a real mouse rather than a first look.
|
||
|
||
### 1.5 Connecting from the palette while on another screen
|
||
|
||
Press Ctrl+K from the FILES screen and connect to a host whose key is not yet approved.
|
||
|
||
**Pass:** the window lands on HOSTS with the fingerprint prompt visible and answerable.
|
||
|
||
**Failure means:** the prompt is behind the screen that asked for it, and the connection is blocked on a
|
||
question that cannot be reached.
|
||
|
||
### 1.6 The vault menu draws above the terminal's rectangle · **the one with a precedent**
|
||
|
||
With a terminal open and showing, press the `⌄` beside the Vaults tab.
|
||
|
||
**Pass:** the window leaves the terminal for the Vaults tab as the menu opens, and the menu is drawn whole
|
||
over the screen underneath it — no part of it clipped along the WebView's edge.
|
||
|
||
**Failure means:** the popup is being composited under the renderer's native child window, and the guard
|
||
this design relies on has stopped working. It is not supposed to be possible: `OnVaultMenuPressed` selects
|
||
the Vaults tab *before* opening the flyout, and a page surface is one where the renderer is not drawn — the
|
||
same move QuickConnect makes. `OpeningTheVaultMenu_SelectsTheVaultsTabSoTheTerminalIsNotUnderIt` asserts
|
||
the ordering headlessly, which is as far as a headless test can go: it has no native window, so it cannot
|
||
see what is painted over what. This check is the other half.
|
||
|
||
It is on this list rather than assumed because the note beside the `+` button in `TerminalTabs.axaml`
|
||
refuses a flyout on exactly this reasoning, and `docs/platform-flags.md` records what this project has
|
||
already paid for treating a rendering claim as settled without looking.
|
||
|
||
### 1.7 Switching a vault off does not switch it out
|
||
|
||
With a team vault holding at least one host: press `⌄` beside Vaults, switch the team vault off, and check
|
||
the hosts screen, the keychain and the pins.
|
||
|
||
**Pass:** that vault's hosts, keys and pins are gone from all three; the vault is still in the "file this
|
||
into" picker on a host editor; the sync indicator still settles rather than stalling; and a host in another
|
||
vault that authenticates with a key filed in the switched-off one still connects.
|
||
|
||
**Failure means:** the filter has reached past the projections it is allowed to touch. See
|
||
`VaultViewModel.IsVaultShown` for the list, and `VaultVisibilityTests` for the same assertions made against
|
||
view models — this check is the version with a real connection on the end of it.
|
||
|
||
---
|
||
|
||
## Phase 2 — Known Hosts as its own page
|
||
|
||
### 2.1 The fingerprint column is readable end to end
|
||
|
||
Connect to two or three hosts, approving each fingerprint. Go to Pins and narrow the window to its minimum
|
||
(1016px since the v2 sidebar; it was 880 while the rail was 54 wide), then widen it to something ordinary.
|
||
|
||
**Pass:** the full `SHA256:…` is on screen at both sizes, never cut off and never ellipsised.
|
||
|
||
**Failure means:** the one thing this screen is for has been broken. A truncated fingerprint cannot be
|
||
compared against a published one — it can only be glanced at, which is the habit pinning exists to replace.
|
||
`TheHostKeysScreenFitsWithPinsAndOneSelected` measures this at the minimum width, so a failure here is a
|
||
size the harness does not cover.
|
||
|
||
### 2.2 The APPROVED date is plausible
|
||
|
||
**Pass:** it is roughly the day you first connected to that host.
|
||
|
||
**Failure means:** the version 7 identifier is being read in the wrong byte order — the symptom is dates
|
||
tens of thousands of years out, not an error. Covered by `Uuid7TimestampTests`, so this is a confirmation
|
||
that the ids reaching the screen really are the ones this client minted. A pin restored from an older
|
||
client or another implementation shows `—`, which is correct rather than a failure.
|
||
|
||
### 2.3 Forgetting a pin reaches the server
|
||
|
||
With two machines signed in to the same account: forget a pin on one, sync the other.
|
||
|
||
**Pass:** the pin is gone on both, and the second machine asks you to check the fingerprint again on the
|
||
next connection.
|
||
|
||
**Failure means:** the forward from the screen's command to the vault's has lost the push. Withdrawing
|
||
trust that stays withdrawn only locally is the failure mode that matters here — the machines still refusing
|
||
to reach a rebuilt server are the other ones.
|
||
|
||
---
|
||
|
||
## Phase 2 — Generating a key
|
||
|
||
Most of this one *is* covered: `KeyAuthenticationTests.AKeyThisClientGenerated_AuthenticatesAgainstARealServer`
|
||
installs a generated public line on a real OpenSSH server in a container and connects with the private half,
|
||
for both algorithms. That is the claim that mattered, and it is automated. What is left is the interface
|
||
around it.
|
||
|
||
### 2.4 COPY PUBLIC KEY actually reaches the clipboard
|
||
|
||
Generate a key, save it, select it, press COPY PUBLIC KEY, then paste somewhere.
|
||
|
||
**Pass:** one `ssh-ed25519 AAAA… comment` line.
|
||
|
||
**Failure means:** the clipboard closure in `App.axaml.cs` is not finding the window. No test can see this —
|
||
the view models take a delegate precisely so they never touch a visual, which means the one real
|
||
implementation of that delegate is exercised by nothing but a person. `CopyingAPublicKey_WithNoClipboard_SaysSo`
|
||
covers only the branch where there is none.
|
||
|
||
### 2.5 RSA-4096 does not freeze the window
|
||
|
||
Choose RSA 4096 and press GENERATE. While it runs, drag the window and click around.
|
||
|
||
**Pass:** the window keeps painting and the status line says it is working.
|
||
|
||
**Failure means:** the `Task.Run` is not actually taking the work off the UI thread. Ed25519 is instant and
|
||
will not show this, so it has to be tried with RSA.
|
||
|
||
### 2.6 The generated key works end to end, by hand
|
||
|
||
Generate a key, save it, copy the public line, add it to a real host's `~/.ssh/authorized_keys`, bind the
|
||
host to the key in its editor, and connect.
|
||
|
||
**Pass:** it connects without a password.
|
||
|
||
This duplicates the container test on purpose. The container runs one image; the thing worth knowing is
|
||
that it works against whatever you actually run.
|
||
|
||
---
|
||
|
||
## Phase 2 — Importing ssh_config
|
||
|
||
The parser has 22 cases over the shapes a real file contains, and the end-to-end path is covered by
|
||
`ImportingAnSshConfig_ShowsItFirstAndThenStoresWhatWasTicked`. What no test can do is read *your* file.
|
||
|
||
### 2.7 Scan your own `~/.ssh/config` and read the preview against the file
|
||
|
||
Preferences → IMPORT HOSTS → SCAN. Do not press import yet.
|
||
|
||
**Pass:** every entry you would expect is listed, with the address and port you expect, and the warnings
|
||
above the table account for anything missing.
|
||
|
||
**What to look for specifically:**
|
||
- A `Host *` block's `User` should appear on hosts that set none, and **not** override hosts that set one.
|
||
- Entries whose name is a pattern (`*.internal`, `bastion-?`) should be *absent* from the table and named
|
||
in the warnings.
|
||
- `Match` blocks should be counted in the warnings and their settings should not have leaked onto any host.
|
||
- A `ProxyCommand` should be reported as dropped, not silently kept.
|
||
|
||
**Failure means:** the importer and `ssh` disagree about what your file means, which produces bookmarks
|
||
that nearly connect. That is worse than an import that refused, so it is worth reading the table properly
|
||
once.
|
||
|
||
### 2.8 Nothing is written until the button
|
||
|
||
Scan, then navigate away without importing.
|
||
|
||
**Pass:** the Hosts screen is unchanged.
|
||
|
||
### 2.9 Imported hosts are correct
|
||
|
||
Import a couple with the key tick **off**, then open one on the Hosts screen.
|
||
|
||
**Pass:** the address, port and username match the config, and the notes record any `IdentityFile` path and
|
||
any `ProxyJump` — with `ProxyJump` clearly stated as not routing. Connecting asks for a password even where
|
||
the config named a key, because with the tick off **no key material is read**; binding it to a key in the
|
||
keychain is a separate act.
|
||
|
||
### 2.10 The key tick reads `~/.ssh`, and only when it is ticked · **the one worth the most care**
|
||
|
||
Scan a config that names at least one `IdentityFile`.
|
||
|
||
**Pass:** below the list there is a tick reading *Also import the private keys these hosts point at*, and it
|
||
is **off**. On a config that names no `IdentityFile` anywhere there is no tick at all.
|
||
|
||
Turn it on without pressing anything else.
|
||
|
||
**Pass:** a warning appears naming what IMPORT will read, and the rows whose config named a key change from
|
||
*a key on disk* to *a key, imported*. Nothing has been read yet — turn it off again and navigate away, and
|
||
the point is that no private key was ever opened.
|
||
|
||
Turn it on and press IMPORT.
|
||
|
||
**Pass:** the keychain holds one key per **file**, not per host — a config pointing twelve entries at one
|
||
`~/.ssh/id_ed25519` produces one key, and all twelve hosts show `key` as their authentication. The key's
|
||
notes name the file it came from. A report appears under the button with one line per file.
|
||
|
||
**Pass, and this is the half that is easiest to lose:** for a key protected by a passphrase, the report says
|
||
so and names the file. The key is still imported; what it does not have is the passphrase, which is nowhere
|
||
on disk to be read. Add it on the keychain screen and the host connects.
|
||
|
||
Now import the same config again.
|
||
|
||
**Pass:** the hosts duplicate — a second bookmark for one machine is allowed and takes a click — and the
|
||
**key does not**. The report should be identical and the keychain should still hold one.
|
||
|
||
**Failure means:** a tick that is on by default, or one that reads keys during SCAN, is the failure that
|
||
matters here — this is the only control in the application that opens private key material out of a
|
||
directory the user did not point at file by file, and the whole of what makes it acceptable is that it took
|
||
a deliberate press. A second copy of a key on a re-import means the material comparison in
|
||
`VaultViewModel.ImportHostsAsync` is not finding what is already there, which turns "import my config"
|
||
into a way to fill a keychain with duplicates of one file.
|
||
|
||
### 2.11 A key file that is not on this machine leaves the host alone
|
||
|
||
Point an `IdentityFile` at a path that does not exist, tick the box and import.
|
||
|
||
**Pass:** the host is imported, unbound, exactly as it would have been with the tick off — and the report
|
||
names the file and says there is no such file. One bad path must not stop the other entries.
|
||
|
||
**Failure means:** an import that stopped, or a host that vanished, on a config carried from another
|
||
machine. That is the ordinary case rather than an error.
|
||
|
||
---
|
||
|
||
## Phase 2 — Drag and drop on the SFTP page
|
||
|
||
**This is the least-covered thing in the repository, and unavoidably so.** Headless Avalonia has no native
|
||
window and cannot synthesise a platform drag, so a test that claimed to drop a file from Explorer would
|
||
pass while confirming nothing. What is automated is the policy — `TransferQueueingTests` covers what may be
|
||
queued, what is skipped and what is said about it — and the wiring between a real drag and that policy is
|
||
covered by nothing at all.
|
||
|
||
Connect the SFTP page to a host first. All four of these should queue transfers.
|
||
|
||
### 2.12 Explorer → remote pane
|
||
|
||
Drag one file, then several, from Explorer onto the right-hand pane.
|
||
|
||
**Pass:** the pane outlines in accent colour while the pointer is over it, and the drop queues one transfer
|
||
per file into the directory showing.
|
||
|
||
### 2.13 Local pane → remote pane
|
||
|
||
**Pass:** as above. This uses the same platform file format as the Explorer drag, so a failure here with
|
||
2.12 passing points at the drag *source*, not the drop target.
|
||
|
||
### 2.14 Remote pane → local pane
|
||
|
||
**Pass:** the left pane outlines and the drop queues a download.
|
||
|
||
### 2.15 Local pane → Explorer
|
||
|
||
**Pass:** the file copies out.
|
||
|
||
### 2.16 The highlight clears · **the one most likely to be wrong**
|
||
|
||
Drag something over a pane and then out of it again without dropping.
|
||
|
||
**Pass:** the outline appears and then goes away.
|
||
|
||
**Failure means:** an overlay is participating in hit testing. It lays out identically either way — which is
|
||
why the layout test cannot catch it — but once visible it swallows the `DragOver` events underneath it, so
|
||
the pointer appears to leave immediately, the highlight sticks, and the drop lands nowhere. The fix is
|
||
`IsHitTestVisible="False"` on the highlight `Border` in `TransfersScreen.axaml`.
|
||
|
||
### 2.17 Dropping while disconnected
|
||
|
||
Disconnect, then drag a file over the remote pane.
|
||
|
||
**Pass:** the pane outlines in red and says "Connect to a host first." Nothing is queued on drop.
|
||
|
||
### 2.18 A click still selects a row
|
||
|
||
Click rows in both panes, and drag a row a few pixels without releasing.
|
||
|
||
**Pass:** a click selects; a small movement does not start a drag.
|
||
|
||
**Failure means:** the 4-pixel threshold in `TransfersScreen.axaml.cs` is not doing its job, and selecting a
|
||
row has become impossible.
|
||
|
||
### Not implemented: remote pane → Explorer
|
||
|
||
Dragging a *remote* file out to Explorer is deliberately absent. It needs the source to supply a virtual
|
||
file — on Windows, `CFSTR_FILEDESCRIPTORW` plus `CFSTR_FILECONTENTS` with delayed rendering — and Avalonia's
|
||
`IDataTransfer` marshals `DataFormat.File` only from a storage item that resolves to a real local path.
|
||
Pre-downloading to a temp file does not help: the shell demands the bytes during the drop. The only route is
|
||
a native COM `IDataObject` behind a platform interface, Windows-only and outside Avalonia's supported
|
||
surface. Use the ← button, or drag the file to the local pane first.
|
||
|
||
---
|
||
|
||
## Phase 3 — Host groups and snippets
|
||
|
||
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
|
||
|
||
Open the hosts screen without creating any group.
|
||
|
||
**Pass:** the grid is the flat wrap of host cards it always was — no GROUPS section above it, no chip on any
|
||
card, nothing saying the hosts are unfiled.
|
||
|
||
**Failure means:** the "invisible until used" property is gone, and every existing user gets a section they
|
||
did not ask for. `HasGroups` is what hides the row of group cards; `HostRowViewModel.HasGroup` hides the chip.
|
||
|
||
### 3.2 Filing hosts, and the grid being one level at a time
|
||
|
||
Make two groups and file some hosts into each through the host editor, leaving at least one host unfiled.
|
||
|
||
**Pass:** the filed hosts **leave the grid** as they are filed — a group is a place, not a label, and the
|
||
grid holds one level of it the way a directory pane holds one directory. What is left is the unfiled hosts.
|
||
**There is no heading and no fold on the desktop** — the headings, their chevrons and UNGROUPED are the
|
||
phone's, whose list has no room for a row of group cards and draws the whole tree flat instead. With every
|
||
host filed, the grid says so in a sentence rather than sitting empty.
|
||
|
||
**Then press a group card once.** It is marked as chosen and **nothing else happens** — the grid is still the
|
||
level it was, and no buttons appear beside the GROUPS heading: editing and deleting a group are on the card's
|
||
own right-click menu, which is 7.9. **Then double-press it.** The group opens: its hosts
|
||
are the grid, the trail above the cards reads `ALL HOSTS › <name> ›`, each card carrying the group's name as
|
||
an accent chip, and the card grid shows what is *inside* that group rather than every group in the keychain.
|
||
Pressing ALL HOSTS goes back to the outermost level.
|
||
|
||
**Then type a filed host's name into the find box at the top.** It is found from the outermost level,
|
||
wherever it was filed and however deep, with the chip on its card saying which group it came out of. Clearing
|
||
the box puts it away again. Inside a group the same box reaches that group and everything under it, and the
|
||
empty-grid sentence there offers ALL HOSTS as the way to widen it.
|
||
|
||
**Failure means:** if one press still narrows the grid, the card `ListBox` is bound to the wrong property —
|
||
`SelectedItem` is `SelectedGroup`, and only `OpenGroupCommand` writes `GroupFilter`. Filed hosts still on the
|
||
outermost level is `Matches` no longer comparing the host's group to the open one. A search that cannot find
|
||
a filed host is worse than either: it is the box answering "no host matches that" about a machine the
|
||
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.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.
|
||
|
||
**Pass:** only the outer group has a card to start with. Double-press it and the inner one is the only card
|
||
shown, with the trail reading `ALL HOSTS › <outer> ›`. Double-press that, and the cards disappear entirely —
|
||
it has nothing inside it — while the trail stays. Pressing the **middle** crumb goes back one level rather
|
||
than all the way out, which is also how a group with nothing inside it is renamed: back out to the level
|
||
where it has a card, and right-click that.
|
||
|
||
**Failure means:** cards for groups that are not at this level is `VisibleGroups` having been bound past —
|
||
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.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.3 Deleting a group with hosts in it
|
||
|
||
Right-click a group with hosts in it and choose **Delete…**. Do it twice: once leaving the tick alone, and
|
||
once — on another group — ticking it.
|
||
|
||
**Pass:** the question names how many hosts are filed under it, says they stay and move to UNGROUPED, and
|
||
offers a tick that would delete them as well. The tick starts clear, and it starts clear again on the next
|
||
group even if it was set on the last one. Left clear, agreeing removes the group and the hosts stay, without
|
||
a chip and otherwise unchanged. Ticked, the hosts go with it — and only the hosts that were filed under that
|
||
group. A group with nothing under it is asked no second question and shows no tick.
|
||
|
||
**Failure means:** a tick that carries from one question to the next is the reset in `OnPendingDeletionChanged`
|
||
having gone, and it deletes machines on the strength of a decision about a different group. Hosts that keep
|
||
the chip after an unticked delete are the unfiling not happening: they still name a group that is gone, which
|
||
is what this used to do on purpose and no longer should.
|
||
|
||
### 3.3a Moving a group to another vault · **needs a second vault**
|
||
|
||
Build `outer › inner` with a host in `inner`, all in your personal vault, then right-click **outer** and
|
||
choose **Move to another vault…**. Pick the shared vault and press MOVE.
|
||
|
||
**Pass:** the panel says what travels and what does not before you press anything. Afterwards all three items
|
||
carry the destination's badge, `inner` is still inside `outer` and the host is still inside `inner` — every
|
||
one of them under an id it did not have a moment ago. The sentence names the vault, the counts, and the fact
|
||
that the group now sits at the top level if it was nested. Nothing is left behind in the vault it came from.
|
||
|
||
**Failure means:** a host under UNGROUPED in the destination is the group id having been carried across
|
||
rather than remapped — the ids are the destination's making, so every reference has to be rewritten as its
|
||
target lands. Anything still in the source vault is a partial move, which is survivable by design but should
|
||
not happen with the network up: the groups are written top-down and the hosts last, so an interruption leaves
|
||
hosts behind and never a shelf with nothing on it.
|
||
|
||
### 3.3b Moving a key, and moving a host with its key · **needs a second vault**
|
||
|
||
In your personal vault: add an SSH key, then two hosts that both authenticate with it. On the Keychain
|
||
screen select the key and press **MOVE**, and read the panel before choosing the shared vault.
|
||
|
||
**Pass:** the panel says what uses the key — "Used by 2 hosts…" — before anything happens. Afterwards the key
|
||
carries the destination's badge under an id it did not have a moment ago, *and both hosts still say `key`
|
||
under their names* and still connect. The sentence names the vault and the two hosts that followed it.
|
||
|
||
Then the other direction: with a key back in your personal vault and a host bound to it, choose **Move to
|
||
another vault…** on the host and pick the shared vault. The tick box under the picker offers to bring the
|
||
key, unticked, with the count of what else uses it underneath. Leave it and press MOVE; then move the host
|
||
back, tick it, and press MOVE again.
|
||
|
||
**Pass:** unticked, the host lands in the shared vault and the status line says the key it authenticates with
|
||
is in another vault and will not resolve for anybody else there. Ticked, the key lands in the shared vault
|
||
too and the host still says `key`. Choosing a *different* vault in the picker re-asks the question, and the
|
||
box disappears when the destination is the vault the key is already in.
|
||
|
||
**Failure means:** a host that says `password` after either move is the re-aim not having happened — the item
|
||
takes a new id in the destination, so every host bound to it and every group lending it has to be rewritten
|
||
as it lands, and a host bound to something its vault no longer holds refuses to connect rather than falling
|
||
back to a typed password. A tick box that is ticked when the panel opens is worse than a bug: it moves a
|
||
private key into a shared vault on the strength of a decision nobody made.
|
||
|
||
### 3.4 A group deleted on another machine · **needs two machines**
|
||
|
||
Make a group on machine A, file a host into it, sync. On machine B, sync, then delete the group and sync
|
||
again. Back on A, sync.
|
||
|
||
**Pass:** A shows the host with no chip on its card, as though it were in no group. Open that host's editor:
|
||
the group picker shows "(a group that is no longer here)" and *keeps it selected*. Change the port and save.
|
||
|
||
**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
|
||
at payload schema 4; an older build must show it and refuse to edit it, rather than editing it and dropping
|
||
the group.
|
||
|
||
**Pass:** the older build says the host was written by a newer version. A host with *no* group still edits
|
||
normally there — that is what makes the version a maximum over the fields present rather than a stamp.
|
||
|
||
### 3.6 Inserting a snippet · **the one that cannot be tested here**
|
||
|
||
Open a terminal, go to SNIPS, select a snippet with `Press Enter after inserting this` **off**, and press the
|
||
insert button.
|
||
|
||
**Pass:** the terminal comes forward with the command sitting at the prompt, not run. The button named the
|
||
tab it was going to — check that it named the right one if several are open.
|
||
|
||
**Failure means:** if the command runs by itself, the flag byte or the JavaScript that reads it is wrong. If
|
||
nothing appears at all, the frame reached a pane the page does not have.
|
||
|
||
### 3.7 A multi-line snippet does not run line by line · **the reason the opcode exists**
|
||
|
||
Save a snippet whose command is three lines — `echo one`, `echo two`, `echo three` — with the run flag off,
|
||
and insert it into a **bash or zsh** session.
|
||
|
||
**Pass:** all three lines sit at the prompt as one pending command, and nothing runs until Enter. Modern
|
||
shells turn bracketed paste on, xterm.js sees that in the output stream, and `term.paste` wraps the text.
|
||
|
||
**Failure means:** if the first two lines execute and the third waits, the text went through as plain input
|
||
and the bracketing did not happen — which is the whole failure this frame was added to prevent.
|
||
|
||
**Then insert the same snippet into something with bracketed paste off** — a raw `sh`, or a session inside
|
||
`vi`. The lines *will* run there, and that is correct and unavoidable: without the mode there is no way to
|
||
distinguish pasted newlines from typed ones. It is why the screen says "types this into whatever is there"
|
||
rather than "runs this command".
|
||
|
||
### 3.8 RUN presses Enter
|
||
|
||
Select a snippet with the run flag on and press RUN.
|
||
|
||
**Pass:** the command runs.
|
||
|
||
**Failure means:** if the command appears and does not run, the `\r` is going through `paste` instead of
|
||
`input` — inside the bracketed wrapper it is literal text, so nothing executes.
|
||
|
||
### 3.9 Inserting into a tab whose remote has hung up
|
||
|
||
Open a terminal, `exit` it, leave the tab open, then insert a snippet at it.
|
||
|
||
**Pass:** the screen says that tab is no longer connected, and nothing is claimed to have been sent.
|
||
|
||
### 3.10 Both new kinds reach a second machine · **needs two machines**
|
||
|
||
Make a group and a snippet on A and sync; sync B.
|
||
|
||
**Pass:** both arrive, with their names and — for the snippet — its run flag intact. A snippet whose flag
|
||
arrives *set* when it was saved unset is the one failure here worth stopping for.
|
||
|
||
---
|
||
|
||
## Phase 4 — Logs
|
||
|
||
Both logs are synced item kinds with the whole pipeline covered: payloads round-trip, the server refuses
|
||
every plaintext field, retention is tested against a real vault, the activity hook is tested through the
|
||
repository every kind writes through, and the recursion guard has a test of its own. What is left here is
|
||
the part that only happens across a lock, across a process exit, or across two machines.
|
||
|
||
### 4.1 A connection is recorded when the tab closes, not before
|
||
|
||
Connect to a host, leave the tab open, and open LOGS.
|
||
|
||
**Pass:** the connection is at the top of CONNECTIONS with a green dot and the words **still open** — not a
|
||
dash, and not a duration. Close the tab and press REFRESH: the same connection now has a duration.
|
||
|
||
**Failure means:** a dash instead of "still open" reads as a recording that failed, which is the opposite of
|
||
what is happening. A duration before the tab closes means an entry is being written at open, which would
|
||
also mean it gets written twice.
|
||
|
||
### 4.2 The duration is plausible
|
||
|
||
Connect, wait a measured minute or two, disconnect.
|
||
|
||
**Pass:** the LASTED column agrees with the clock, rounded to whole units.
|
||
|
||
**Failure means:** a wildly wrong number points at the two timestamps coming from different clocks — both
|
||
should come from the workspace's own `TimeProvider`.
|
||
|
||
### 4.3 Closing the application records the tabs that were open · **the one most likely to be wrong**
|
||
|
||
Open two terminals and close the DodoSSH window without closing the tabs. Start it again, unlock, open LOGS.
|
||
|
||
**Pass:** both connections have entries, with durations running up to the moment you closed the window.
|
||
|
||
**Failure means:** they are the ordinary way a session ends, and the workspace's own close-outs happen while
|
||
it tears sessions down — *after* the vault they would be written into has gone. `ConnectionRecorder`'s
|
||
`DisposeAsync` closes the tickets itself, before the session is disposed, and waits up to two seconds for
|
||
the queue. If the entries are missing, that ordering has been broken; if closing the window became slow,
|
||
the bounded wait has.
|
||
|
||
### 4.4 A shell open across a lock still gets its entry
|
||
|
||
Connect to a host, lock the keychain from the titlebar, unlock again, then close the tab.
|
||
|
||
**Pass:** the connection is recorded, into the vault it was made in.
|
||
|
||
**Failure means:** the ticket keeps the repository it was opened against precisely so this works. A missing
|
||
entry means it is reading the current one instead, which would also mean an entry could be filed into the
|
||
wrong vault once shared vaults land.
|
||
|
||
### 4.5 A refused host key is recorded
|
||
|
||
Connect to a host, approve its key, then change the key on the remote (or edit the pin) so the next
|
||
connection is refused.
|
||
|
||
**Pass:** an entry appears with **host key refused** beside it. This is the row the connection log most
|
||
exists for — a changed host key is refused with no way past it, so the status line is otherwise its only
|
||
trace.
|
||
|
||
### 4.6 The SFTP session is recorded separately
|
||
|
||
Open the files screen and connect, then disconnect.
|
||
|
||
**Pass:** an entry with **files** in the KIND column, separate from any terminal entry.
|
||
|
||
**Failure means:** if it is missing, our log disagrees with the remote's own `auth.log`, which records the
|
||
second login. Anybody comparing the two would be right to believe the host.
|
||
|
||
### 4.7 The keychain log records names and never values
|
||
|
||
Edit a stored password: change both the password and the username. Open LOGS → KEYCHAIN.
|
||
|
||
**Pass:** one row saying **changed**, with `Password, Username` in the FIELDS column.
|
||
|
||
**Failure means:** if any part of the old or new password appears anywhere on that screen, stop — that is
|
||
the one thing this payload must never carry, and it would now be synced to every machine in the vault.
|
||
|
||
### 4.8 A pin trusted at the prompt is recorded
|
||
|
||
Connect to a host you have never reached and approve the fingerprint.
|
||
|
||
**Pass:** the keychain log shows a `KnownHostKey` **created**. This write never goes through a screen, so it
|
||
is exactly the one a hook placed in the view models would have missed.
|
||
|
||
### 4.9 The pending count and the status line stay honest
|
||
|
||
Save a host while online and watch the titlebar and the status line for a minute.
|
||
|
||
**Pass:** the pending count returns to zero and stays there, and the status line keeps saying what the save
|
||
said — it does not get overwritten a moment later by a sync report.
|
||
|
||
**Failure means:** every user action queues a log entry a moment afterwards. If the count sticks at 1 or the
|
||
status line flickers to "Synchronised: 1 out", the log entries have stopped being excluded from the two
|
||
numbers that are about the user's own work.
|
||
|
||
### 4.10 Both logs reach a second machine · **needs two machines**
|
||
|
||
Connect and edit something on A, sync; then sync B and open LOGS there.
|
||
|
||
**Pass:** both entries are on B, with A's device name on them. This is the claim the whole decision to sync
|
||
these rests on.
|
||
|
||
### 4.11 Retention actually prunes · **slow, or needs a clock**
|
||
|
||
Only checkable honestly by leaving a vault in use for months, or by temporarily lowering
|
||
`LogRetention.Default` in a debug build and watching a prune remove the excess and push the tombstones.
|
||
|
||
**Pass:** the count comes down, and the second machine's copy comes down too at its next sync.
|
||
|
||
**Failure means:** these entries sync, so a prune that does not push leaves every other machine holding
|
||
them — and this machine deleting them again on every pass.
|
||
|
||
---
|
||
|
||
## Phase 6 — S3 as a remote in the file browser
|
||
|
||
The parts that are this client's own reasoning are covered: the path-to-key translation, what a bucket must
|
||
have before it can be stored, the server's refusal of every plaintext field, and the item kind end to end.
|
||
What is left needs a real endpoint, and a fake would only assert our reading of the protocol back at us.
|
||
|
||
**Get a bucket first.** MinIO in Docker is the cheapest way and exercises the harder path — path-style
|
||
addressing, a custom endpoint, and a region that is ignored:
|
||
|
||
```bash
|
||
docker run -p 9000:9000 -e MINIO_ROOT_USER=dodossh -e MINIO_ROOT_PASSWORD=dodossh-secret minio/minio server /data
|
||
```
|
||
|
||
### 6.1 Adding a bucket
|
||
|
||
Keychain → BUCKETS → `+ BUCKET`. Endpoint `http://localhost:9000`, path-style **on**, any region.
|
||
|
||
**Pass:** it saves, appears in the list with the bucket and endpoint under its name, and syncs.
|
||
|
||
**Failure means:** if saving is refused, read the message — the validation exists so the reason names the
|
||
field rather than arriving later as an SDK error about resolving a URI.
|
||
|
||
### 6.2 Path-style addressing · **the one most likely to be wrong**
|
||
|
||
Save the same bucket with path-style **off** and open it.
|
||
|
||
**Pass:** it fails, and the failure is a name-resolution error mentioning `bucket.localhost`.
|
||
|
||
**Why it is worth doing deliberately:** that is exactly what a user gets when they leave the checkbox at its
|
||
default against a self-hosted service, and the message names neither buckets nor the setting. Seeing it once
|
||
is what makes the hint under the checkbox worth its space.
|
||
|
||
### 6.3 Browsing
|
||
|
||
Files → BUCKET → pick it → OPEN.
|
||
|
||
**Pass:** the right pane lists the bucket root. Prefixes appear as directories in the directory colour;
|
||
objects appear as files with sizes and dates. The PERMS column is empty — a bucket has no POSIX mode, and a
|
||
plausible `-rw-r--r--` would be invented.
|
||
|
||
**Also check** that the timestamps agree with the local pane's. Both columns are UTC; if the bucket's are out
|
||
by your machine's offset, the `DateTime.Kind` handling in `S3FileStore.Utc` has regressed.
|
||
|
||
### 6.4 Upload, and the pipe underneath it
|
||
|
||
Upload a file of a few hundred megabytes.
|
||
|
||
**Pass:** it completes, the object is in the bucket at the right key and the right size, and the machine's
|
||
memory does not grow with the file. The upload streams through a pipe into a multipart upload — nothing is
|
||
buffered to disk twice and nothing is held whole in memory.
|
||
|
||
**Failure means:** if it hangs at the end, the pipe's writer is not being completed on disposal. If memory
|
||
tracks the file size, the multipart path is not being taken.
|
||
|
||
### 6.5 A failed upload surfaces at the write
|
||
|
||
Start an upload and stop MinIO halfway.
|
||
|
||
**Pass:** the queue row fails with a message from the service, reasonably promptly.
|
||
|
||
**Failure means:** if it hangs instead, the background upload is failing without completing the pipe's
|
||
reader — and the copy is blocked on a pipe nobody is draining. That is the case `S3UploadStream` completes
|
||
the reader *with* the exception for.
|
||
|
||
### 6.6 Download, and resume
|
||
|
||
Download a large object, stop it partway, and resume.
|
||
|
||
**Pass:** it resumes from where it stopped, and the finished file matches the original. This direction is the
|
||
one where a bucket is better than SFTP — a ranged GET is part of the protocol.
|
||
|
||
### 6.7 Upload resume is refused, and says why
|
||
|
||
Start an upload, stop it partway, and press RESUME.
|
||
|
||
**Pass:** it fails with a message saying an object cannot be written from the middle, so an interrupted
|
||
upload starts again rather than resuming. RETRY from the start works.
|
||
|
||
**Why this is not a bug:** objects are immutable. Multipart could rebuild an interrupted transfer, but only
|
||
by persisting the upload id and every part's ETag across the interruption. Refusing is honest; silently
|
||
starting from zero would corrupt the file.
|
||
|
||
### 6.8 Delete refuses a prefix with anything under it
|
||
|
||
Try to delete a directory in the bucket that has objects in it.
|
||
|
||
**Pass:** refused, saying there are still objects under it. Deleting an empty one works.
|
||
|
||
### 6.9 The keys never appear anywhere they should not
|
||
|
||
After adding and editing a bucket, open LOGS → KEYCHAIN.
|
||
|
||
**Pass:** the entry says `Secret access key` in the FIELDS column and nowhere on that screen does any part of
|
||
the key itself appear.
|
||
|
||
### 6.10 Against real AWS · **needs an account**
|
||
|
||
Repeat 6.1 and 6.3 with the endpoint blank, a real region, and path-style **off**.
|
||
|
||
**Pass:** it lists. This is the path that exercises `RegionEndpoint.GetBySystemName` and virtual-host
|
||
addressing, neither of which MinIO covers.
|
||
|
||
---
|
||
|
||
## Phase 7 — Connecting without waiting, and the host list's pointer
|
||
|
||
Connecting no longer holds the vault while it happens: a tab appears at once and the handshake runs behind
|
||
it. The state machine around that is covered in `ShellFlowTests` — the tab, the card, the two kinds of
|
||
failure, two connections at once — and the card itself is measured. What is left needs a real window: a
|
||
native drag, a real menu popup, and a host that takes its time answering.
|
||
|
||
### 7.1 The window stays usable while a connection is being made · **the point of the change**
|
||
|
||
Connect to something that will take a while — an address that is routable but silent, so the handshake sits
|
||
there rather than failing at once (a firewalled IP is ideal; `10.255.255.1` will do). While it runs: click
|
||
around the host list, open KEYS, edit something, connect to a second host.
|
||
|
||
**Pass:** everything works. The strip shows a tab named for the host with `connecting…` beside it, and the
|
||
terminal area shows the host, the address it is dialling, and the same word.
|
||
|
||
**Failure means:** the connect has ended up back inside `RunAsync`'s busy gate, which is the one thing
|
||
`VaultViewModel.ConnectAsync` must never be put inside again.
|
||
|
||
### 7.2 Navigating away during a connection unlights the tab
|
||
|
||
While 7.1's connection is still running, click KEYS.
|
||
|
||
**Pass:** the tab stays in the strip and stops being marked as the active one. No accent bar along its top
|
||
edge while a page is showing.
|
||
|
||
**Failure means:** the tab is bound to `IsSelected` again rather than to `IsShowing`, and the strip is
|
||
claiming to be what the window is showing when it is not.
|
||
|
||
### 7.3 A refusal ends up in the tab, not only in the status line
|
||
|
||
Connect to a host with the wrong password, and navigate to PREFS before it answers.
|
||
|
||
**Pass:** the tab goes grey and carries the refusal; selecting it shows the reason in the middle of the
|
||
window with a CLOSE TAB button. Closing it returns to the page underneath.
|
||
|
||
**Failure means:** a connection that failed while the user was elsewhere has left no trace anywhere they
|
||
would look.
|
||
|
||
### 7.4 An unknown host key still wins the window
|
||
|
||
Connect to a host whose key is not yet approved.
|
||
|
||
**Pass:** no tab is left behind, and the window is on HOSTS with the fingerprint prompt. Approving it
|
||
connects and gives the tab a pane. This is 1.5 with the tab rework underneath it.
|
||
|
||
### 7.5 Giving up on a connection, and what arrives afterwards
|
||
|
||
Start 7.1's slow connection and press GIVE UP (or the tab's cross) while it is still trying.
|
||
|
||
**Pass:** the tab goes at once. If the host does eventually answer, a tab appears for the session that
|
||
opened — it is a real shell, and one running with nothing naming it would be worse than one that comes
|
||
back.
|
||
|
||
### 7.6 Dragging a host onto a group card · **least covered, like all drag and drop**
|
||
|
||
Make two groups and file a host into one. Drag a host card up onto the other group's card.
|
||
|
||
**Pass:** the group card under the pointer takes a two-pixel accent border while the pointer is over it, the
|
||
cursor shows a move rather than a refusal, and the drop files the host — **the card leaves the grid**, going
|
||
inside the group it was dropped on, the host counts under both group cards change, and the status line says
|
||
where it went. That sentence is the only thing left saying so, which is why it is worth reading: the card
|
||
itself is on the level below now, and nothing is selected once it has gone.
|
||
|
||
**Also check three refusals**, each of which must show the "no" cursor and mark nothing: over the card of the
|
||
group the host is *already* in — type its name into the find box first, which is what brings a filed card
|
||
back to this level; over another **host** card, which is deliberately not a target now that there are no
|
||
headings to say which group it would mean; and over the empty space around the cards.
|
||
|
||
**The targets are the cards on screen, which are one level** — see 3.2a. Filing into a group nested under
|
||
another means opening the outer one first, exactly as moving a file into a subfolder does.
|
||
|
||
**And getting a host back out** is the host's own editor — pick "No group" in its picker. There is no
|
||
UNGROUPED target on the desktop any more, because there is no UNGROUPED heading for it to be.
|
||
|
||
**And a drag held near the top or bottom edge of the grid scrolls it**, which is what makes this usable at
|
||
all with forty machines: the group cards are the first thing in the scroller, and a drag cannot use the
|
||
wheel. The pointer has to keep moving inside the band — a stationary pointer gets no drag events.
|
||
|
||
**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_FilesItAndTakesItOffTheLevelItCameFrom`.
|
||
|
||
### 7.7 A click still selects, and a double click still connects
|
||
|
||
Click host cards; drag one a few pixels without releasing; double-click one. Then double-click the space
|
||
around the cards.
|
||
|
||
**Pass:** a click selects, a small movement starts nothing, and a double click connects. Double-clicking the
|
||
space around the cards connects to nothing.
|
||
|
||
**Failure means:** the 5-pixel threshold in `HostsScreen.axaml.cs` is not doing its job — the same failure
|
||
as 2.18 on the other screen, and here it would make the grid unusable. Empty space that connects means the
|
||
double-tap handler has lost its check that the pointer was over a card.
|
||
|
||
**And the same two gestures on the group cards above**, where they mean select and open rather than select
|
||
and connect — the space around *those* cards must open nothing, for the same reason. See 3.2.
|
||
|
||
### 7.8 The highlight clears after a drag that goes nowhere
|
||
|
||
Drag a host over a group card and release outside the grid, or press Escape mid-drag.
|
||
|
||
**Pass:** the wash goes away.
|
||
|
||
### 7.9 The right-click menu acts on the row under the pointer
|
||
|
||
With host A selected, right-click host B and choose Delete.
|
||
|
||
**Pass:** the question names **B**. Then right-click the space around the cards.
|
||
|
||
**Pass:** no menu opens at all, and the host selection has not moved.
|
||
|
||
**Failure means:** a menu acting on the selection rather than on the card under the pointer deletes the
|
||
wrong machine. `HostGridTests` covers both halves headlessly, so this is a confirmation that a real popup
|
||
behaves as the headless one did.
|
||
|
||
**And the same on the group cards above.** Open a group, then right-click a card inside it and choose
|
||
Delete.
|
||
|
||
**Pass:** the question names the **card**, not the group that is open — and Open on that menu goes into the
|
||
card, rather than back out to ALL HOSTS. Right-clicking the space around the group cards opens no menu.
|
||
|
||
**Failure means:** the menu is reading `GroupTarget`'s fallback, which is the group whose contents are on
|
||
screen rather than the card the pointer is on. This menu is the only way to edit or delete a group on the
|
||
desktop — there are no buttons beside the GROUPS heading any more — so a menu aimed wrongly is the whole of
|
||
the mistake.
|
||
|
||
### 7.10 Clicking a host in the palette connects
|
||
|
||
Ctrl+K, then click a result with the mouse rather than pressing Enter.
|
||
|
||
**Pass:** the palette closes and the connection starts.
|
||
|
||
### 7.11 A host or a bucket made now can be picked on FILES now
|
||
|
||
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
|
||
add a bucket — then come back.
|
||
|
||
**Pass:** the new one is in the picker, without locking and unlocking.
|
||
|
||
**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 and removing on the phone's host list
|
||
|
||
Thirteen checks, and the reason there are thirteen rather than none is worth stating: **the layout suite
|
||
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
|
||
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
|
||
application, dispatcher and platform are one-shot process globals, so a second head cannot share the
|
||
process either; and the phone's numbers only mean anything measured under the phone's own style graph.
|
||
Every property and command below is covered headlessly in `ShellFlowTests` — the flow is shared, and only
|
||
the rectangles are not. So what follows is the rectangles.
|
||
|
||
Run on a 360dp-wide device or emulator, unlocked, on HOSTS.
|
||
|
||
### 8.0 It launches at all · **thirty seconds, and it has caught a shipped crash**
|
||
|
||
Install the APK and open it.
|
||
|
||
**Pass:** the opening screen appears.
|
||
|
||
**Failure means:** nothing on this head is covered by a test — see the paragraph above — so a view that
|
||
throws while it is being built takes the launch with it and no build gate anywhere says so. The one that
|
||
shipped was a generated `x:Name` field used from a constructor, which is null in every view in this
|
||
repository; see `docs/platform-flags.md`. Get the stack with `adb logcat -d -b crash`, which names the view.
|
||
|
||
Every check below assumes this one passed, and none of them can be reached if it did not.
|
||
|
||
### 8.1 A wide surface draws the rail, and a narrow one draws the bar · **needs a tablet, a foldable or a resizable window**
|
||
|
||
Open the application on something at least 600dp wide — a tablet, an unfolded foldable, a phone in
|
||
landscape, or a freeform window dragged wide.
|
||
|
||
**Pass:** a rail down the left edge carrying Hosts, Connections, Keychain, Snippets, SFTP, S3, Logs, Vaults
|
||
and Preferences, with Preferences at the foot. **No bar across the bottom.** The header with the vault's
|
||
name, the sync light and LOCK is up, and stays up on every one of those screens rather than disappearing on
|
||
the keychain the way it does on a phone.
|
||
|
||
Now make it narrow again — fold it, rotate it, drag the window in.
|
||
|
||
**Pass:** the rail goes, the bottom bar comes back with its three entries, and the screens behind SETTINGS
|
||
have their back arrows again.
|
||
|
||
Go to SETTINGS on the narrow surface, then widen it without touching anything else.
|
||
|
||
**Pass:** it lands on Hosts rather than sitting on the hub. The hub is a list of the destinations the rail
|
||
now carries, so staying there would be a menu of things visible beside it.
|
||
|
||
On the wide surface, open the keychain and press the system back gesture.
|
||
|
||
**Pass:** Hosts. Not the hub — that screen is not drawn here, and backing into it would strand somebody on
|
||
a surface with no way off it but a second back.
|
||
|
||
**Failure means:** a rail *and* a bottom bar at once is `ShowsRail` and `ShowsBottomBar` no longer being
|
||
each other's opposite. A back arrow on a wide surface leads to a screen the layout removed. And a bar that
|
||
never becomes a rail is the resize handler having gone back to returning early when the keyboard is
|
||
closed — which is how a foldable would open to a phone layout until somebody typed something.
|
||
|
||
### 8.2 The + is reachable and is not covering anything
|
||
|
||
Look at the bottom right of the host list with no host selected.
|
||
|
||
**Pass:** a filled accent circle, 56×56, clear of the bottom edge and of the last row's text. Then press
|
||
and hold a host so the connect bar rises.
|
||
|
||
**Pass:** the + has moved up with the bar and is not over the CONNECT button.
|
||
|
||
**Failure means:** the + was anchored to the screen rather than to the list's row. Nothing in the layout
|
||
harness would catch it if it could run — its only fit rule is containment inside the window, never overlap
|
||
between two controls, so a button sitting squarely on top of CONNECT reports clean.
|
||
|
||
### 8.3 The sheet says what each choice does
|
||
|
||
Tap the +.
|
||
|
||
**Pass:** a sheet rises from the bottom with a grab handle, two rows — **New host** and **New group** — each
|
||
with a sentence under it, and CANCEL. The scrim darkens the list.
|
||
|
||
**Note:** the four nav destinations underneath stay lit and tappable. That is deliberate — this is a menu,
|
||
not a decision, and navigating away is a fine way to answer it. Compare the changed-host-key screen, which
|
||
must offer no way out but BACK.
|
||
|
||
### 8.4 The sheet is dismissible three ways
|
||
|
||
Tap the scrim. Reopen, tap CANCEL. Reopen, use the system back gesture.
|
||
|
||
**Pass:** all three lower it, and none of them opens an editor.
|
||
|
||
**Failure means:** back is being handled by the screen switch rather than by the guard above it, and the
|
||
gesture has left HOSTS instead of closing the sheet.
|
||
|
||
### 8.5 An empty port box says what it will dial
|
||
|
||
Add a group with a default port of 2222, then tap + → New host and choose that group in the GROUP picker.
|
||
|
||
**Pass:** the port box is **empty**, and the greyed text inside it changes to `2222` the moment the group is
|
||
chosen. The username box behaves the same way.
|
||
|
||
**Failure means:** the editor loaded a resolved value into the box instead of a placeholder behind it —
|
||
saving would then pin 2222 onto the host, and changing the group later would leave that host behind.
|
||
|
||
### 8.6 A host created that way inherits rather than copies
|
||
|
||
Save that host, then edit the group's default port to 2200 and look at the host's row.
|
||
|
||
**Pass:** the address now reads `…:2200`. Connect: the session goes to 2200.
|
||
|
||
**Failure means:** the port was copied at creation and the group is a one-shot template.
|
||
|
||
### 8.7 Filing a host into a group does not silently pin its password
|
||
|
||
Add a group with a default SSH key. Add a host with no group and leave AUTHENTICATION on **Password (ask
|
||
each time)**. Save. Now edit it and choose the group.
|
||
|
||
**Pass:** the picker gains an **Inherit from group** entry and moves to it. Save, and the host row reads
|
||
`key`, and the connect bar shows a note naming the group rather than a password box.
|
||
|
||
**Failure means:** the picker's "Password (ask each time)" was written as a decision when it was only the
|
||
absence of one, and this host will never use the fleet's key.
|
||
|
||
### 8.8 A host can still refuse its group's key
|
||
|
||
On that same host, set AUTHENTICATION back to **Password (ask each time)** and save.
|
||
|
||
**Pass:** the connect bar shows a password box again, and it survives a lock and unlock.
|
||
|
||
**Failure means:** the fourth state has no storage and the host is inheriting whatever the group says.
|
||
|
||
### 8.9 Back closes the topmost thing, one at a time
|
||
|
||
Tap + → New host, then use the back gesture.
|
||
|
||
**Pass:** the editor closes and the list is back, with the boxes cleared. Back again leaves the application
|
||
— HOSTS is the screen the app opens on and back from it should exit, as it always did.
|
||
|
||
Repeat with the group menu: tap the `⋯` on a group heading, then back.
|
||
|
||
**Pass:** the menu closes and nothing is armed behind it — no editor, no question, no move panel.
|
||
|
||
### 8.10 Every target is a thumb's size
|
||
|
||
Walk both sheets' rows, the editor's fields, both pickers, the checkbox and the four buttons.
|
||
|
||
**Pass:** nothing is under 44 effective pixels tall.
|
||
|
||
**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.
|
||
|
||
### 8.11 The group menu names its group, and the heading still fits
|
||
|
||
Needs a vault with at least one group holding a host. On HOSTS, look at the group's heading row first.
|
||
|
||
**Pass:** chevron, name, count and a `⋯` all on one line, with the name trimmed rather than pushing the
|
||
count off the right edge. On a shared session the vault badge is between the name and the count and the name
|
||
gives way before the badge does.
|
||
|
||
Tap the `⋯`.
|
||
|
||
**Pass:** a sheet rises carrying **GROUP** and the group's own name, then three rows — Edit, Move to another
|
||
vault, a rule, and Delete in the danger colour — each with a sentence under it, and CANCEL. Tapping the
|
||
scrim closes it.
|
||
|
||
**Failure means:** if the name is missing, the sheet is bound to the wrong thing; the whole point of naming
|
||
it is that the heading it was raised from may have scrolled away under the scrim.
|
||
|
||
### 8.12 Deleting a group asks the second question
|
||
|
||
`⋯` → Delete on a group with at least two hosts filed under it.
|
||
|
||
**Pass:** a question above the list, naming the group, saying how many hosts are filed under it and that
|
||
left alone they move to UNGROUPED — with an **unticked** box offering to delete them as well, below the
|
||
count and not above it. DELETE and KEEP under that.
|
||
|
||
Press KEEP, then raise the question again on a *different* group.
|
||
|
||
**Pass:** the box is unticked again. A tick carried over from the last question would delete a second
|
||
group's machines on the strength of a decision about the first.
|
||
|
||
Now tick it and press DELETE.
|
||
|
||
**Pass:** the group and its hosts are gone. Untick it on a third group and the hosts survive under UNGROUPED.
|
||
|
||
### 8.13 Moving a group names the shelf · **needs a second writable vault**
|
||
|
||
`⋯` → Move to another vault.
|
||
|
||
**Pass:** a panel above the list reading **MOVE GROUP** and the group's name, a vault picker that does not
|
||
offer the vault it is already in, the paragraph about what stays behind, and MOVE / CANCEL.
|
||
|
||
Press MOVE.
|
||
|
||
**Pass:** the group, the groups nested inside it and every host under any of them are in the other vault,
|
||
the group is at the top level, and the status line says so.
|
||
|
||
**Failure means:** if MOVE appears to do nothing at all, the command is reading the desktop's group
|
||
selection, which this head never sets. That is the exact failure the header-aimed commands exist to prevent
|
||
— see `VaultViewModel.MoveGroupFromHeading`.
|
||
|
||
With only one writable vault, the entry answers on the status line instead and no panel opens. That is the
|
||
intended answer, not a failure.
|
||
|
||
### 8.14 DELETE under a host is not beside EDIT
|
||
|
||
Press and hold a host so the connect bar rises.
|
||
|
||
**Pass:** CONNECT, then EDIT and MOVE side by side, then DELETE full width in the danger colour on a row of
|
||
its own beneath them. On a host with nowhere to move to, MOVE is absent and the row is EDIT alone.
|
||
|
||
Press DELETE.
|
||
|
||
**Pass:** the question takes the whole bar — no CONNECT, no EDIT, no second DELETE underneath it — and KEEP
|
||
puts them back with the host still there.
|
||
|
||
**Failure means:** a bar that kept its buttons under the question is one where DELETE can be pressed twice,
|
||
the second press landing on whatever the list rebuilt underneath.
|
||
|
||
|
||
### 8.15 A tap connects, and a long press is what raises the bar · **the one worth the most care**
|
||
|
||
On a host bound to a key or a stored credential — one that needs nothing typed — tap the row once.
|
||
|
||
**Pass:** it connects, and the terminal takes the screen. **Nothing rises over the list on the way past** —
|
||
no password box, no CONNECT, no EDIT/MOVE/DELETE. Come back to HOSTS: still nothing.
|
||
|
||
Now press and hold the same row.
|
||
|
||
**Pass:** the bar comes up, naming that host, with all of it. Press and hold a different row: the bar
|
||
follows to that one rather than staying on the first.
|
||
|
||
Now tap a host that authenticates with a **typed password**.
|
||
|
||
**Pass:** it does not dial. The bar rises with the password box in it and the status line says the host asks
|
||
for a password. Type it and press CONNECT — or tap the row a second time, which is the same thing — and it
|
||
connects.
|
||
|
||
**Failure means:** a tap that raises the bar is the old behaviour, where choosing a machine put five
|
||
controls over the bottom of the list in the way of the one thing a tap on its name means. A tap that dials
|
||
a password host with an empty box is worse: the connection fails somewhere the user cannot see why. A long
|
||
press that connects means `held` in `HostsScreen.axaml.cs` is not suppressing the `Tapped` that follows a
|
||
hold, so one touch fires both gestures.
|
||
|
||
### 8.16 The pencil in the header edits the chosen host
|
||
|
||
With a host chosen — by a tap that connected, or by a long press — look at the header beside LOCK.
|
||
|
||
**Pass:** a pencil. It opens that host's editor. With nothing chosen there is no pencil at all rather than a
|
||
greyed one, and while an editor is open it is gone.
|
||
|
||
Navigate to Settings and back.
|
||
|
||
**Pass:** the pencil is absent on every screen but HOSTS, including Settings and the keychain.
|
||
|
||
**Failure means:** a pencil visible over another screen is the `IsHostsShowing` wrapper gone — see
|
||
`PhoneShell.axaml`, which nests the two conditions because Avalonia's bindings have no "and". A pencil that
|
||
stays while the host editor is up is `CanEditSelectedHost` no longer reading `AnEditorIsOpen`.
|
||
|
||
---
|
||
|
||
## 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
|
||
host so the chip row that wraps is what gets laid out. Two things it still cannot answer, and the phone's
|
||
side of it remains unmeasurable for the reasons in phase 8.
|
||
|
||
### 9.1 The desktop host editor scrolls rather than losing its buttons
|
||
|
||
Open a host's editor with a keychain holding a dozen tags.
|
||
|
||
**Pass:** the drawer scrolls, and FORGET HOST KEY is reachable at the bottom of it.
|
||
|
||
**Failure means:** the drawer's ScrollViewer is gone. The layout harness skips anything inside one, so from
|
||
that commit on it certifies the drawer fits rather than the fields — it will tell you the drawer is fine
|
||
while the last button sits below the window. The editor used to carry a MaxHeight of its own because the
|
||
host list shared its column; the drawer is alone in its column now, so the height is the window's.
|
||
|
||
### 9.2 A chip toggles and reads as toggled
|
||
|
||
In the picker, tap a tag on and off.
|
||
|
||
**Pass:** worn is filled and accent-coloured, unworn is outlined. The difference is legible at a glance from
|
||
normal reading distance, not only side by side.
|
||
|
||
### 9.3 A tag renamed on one machine renames every chip on the other
|
||
|
||
Tag two hosts, sync, rename the tag on the other machine, sync back.
|
||
|
||
**Pass:** both rows show the new name, and neither host shows as having unsynced changes — renaming a tag
|
||
rewrites no host.
|
||
|
||
**Failure means:** the rename copied a name into the hosts, which is the thing making a tag an item was for.
|
||
|
||
### 9.4 A deleted tag drops the chip and nothing else
|
||
|
||
Delete a tag two hosts wear.
|
||
|
||
**Pass:** the confirmation says how many hosts wear it. After confirming, both rows lose the chip and
|
||
nothing else about them changes. Neither host is queued for push.
|
||
|
||
### 9.5 The phone's picker is usable with a thumb
|
||
|
||
On the phone's host editor with several tags: the chips are at least 36 tall, spaced enough that a miss
|
||
lands between them rather than on the wrong tag, and the new-tag box and ADD sit on one row without either
|
||
being squeezed to nothing.
|
||
|
||
---
|
||
|
||
## Phase 10 — The software keyboard and the boxes that take secrets
|
||
|
||
Every check here needs a real Android device or emulator, and there is no headless equivalent of any of
|
||
them: the software keyboard is an inset the platform reports, and a headless top level reports none.
|
||
Worth running on two devices if you have them — one on Android 14 or earlier and one on Android 15 or
|
||
later — because the interface is kept clear of the keyboard by a different mechanism on each. Before 15 the
|
||
activity's `AdjustResize` has the platform shorten the window; from 15 the window is not resized at all and
|
||
`PhoneShell` applies the reported inset itself. A build that only ever ran on one of the two will look
|
||
correct and be half broken.
|
||
|
||
### 10.1 The vault passphrase box is treated as a password by the keyboard
|
||
|
||
Launch to the lock screen, tap the passphrase box, type a few characters.
|
||
|
||
**Pass:** dots on screen, and **no suggestion strip above the keyboard** — no completions, no previously
|
||
typed words, no autocorrect. Then open any ordinary box on the phone (the host search, a snippet's name)
|
||
and confirm the suggestions come back there.
|
||
|
||
**Failure means:** `TextInputOptions.ContentType` is missing — most likely a box was given `PasswordChar`
|
||
directly instead of `Classes="... secret"`. `PasswordChar` is what the screen draws; the content type is
|
||
what the keyboard is told, and only the second one keeps a passphrase out of the IME's learning
|
||
dictionary. A box showing dots with a suggestion strip over it is the worst case, not a cosmetic one.
|
||
|
||
### 10.2 COPY CODE actually copies, on both heads
|
||
|
||
Enrol a fresh profile. On the recovery-code screen, press **COPY CODE**, then paste somewhere — another
|
||
app on the phone, a text editor on the desktop.
|
||
|
||
**Pass:** the whole code arrives, and the line under the button says to put it in a password manager now.
|
||
|
||
**Failure means:** if the phone says *this machine has no clipboard*, the delegate is not being passed to
|
||
`MainWindowViewModel` again — the phone shipped for a while with none, so every copy on that head said
|
||
exactly that on a device that plainly has one. If nothing is said at all, the button silently no-opped,
|
||
which on this screen is worse than refusing: somebody who believes the code is on their clipboard will not
|
||
write it down, and it is shown once.
|
||
|
||
Worth pasting somewhere you can see all of it. The code is the only thing standing between a forgotten
|
||
passphrase and an unrecoverable vault, and a truncated copy fails silently months later.
|
||
|
||
### 10.3 The keyboard does not cover the box being typed into
|
||
|
||
The same box: with the keyboard up, the passphrase box and the UNLOCK button under it are both visible.
|
||
Repeat on each of the five boxes that take a secret — lock screen, both enrollment boxes, the connect
|
||
password on HOSTS, and the connect password on FILES.
|
||
|
||
**Pass:** the box stays on screen when the keyboard opens, and the interface is shortened rather than slid
|
||
— the header stays where it is rather than scrolling off the top.
|
||
|
||
**Failure means:** on Android 15 or later, the inset is no longer reaching `PhoneShell`. On 14 or earlier,
|
||
`WindowSoftInputMode` has been dropped from the activity and the platform is panning the window instead of
|
||
resizing it — which, for a window whose whole content is one native view, pans by nothing useful.
|
||
|
||
### 10.4 Nothing is stranded when the keyboard closes
|
||
|
||
Dismiss the keyboard with back or the down-chevron from each of those screens.
|
||
|
||
**Pass:** the interface fills the screen again immediately, with no band of empty canvas left along the
|
||
bottom and no scroll position left part way down.
|
||
|
||
**Failure means:** the inset is being applied but not cleared — the closed state is not being read from the
|
||
event, or the margin is only ever added to.
|
||
|
||
### 10.5 Rotating with the keyboard up
|
||
|
||
Focus a passphrase box, then turn the phone sideways.
|
||
|
||
**Pass:** the box is still visible and still focused, and the shell is intact — the activity handles the
|
||
rotation rather than being recreated, and live shells survive it.
|
||
|
||
---
|
||
|
||
## Phase 11 — The phone's terminal surface
|
||
|
||
Every check here needs a real device for the reason Phase 10's do, plus one of its own: the interesting
|
||
question on this screen is whether a native `WebView` composites above what Avalonia draws over it, and no
|
||
headless surface has a native view to answer with. `docs/android-port.md` still records that as unverified;
|
||
11.2 is the check that settles it.
|
||
|
||
### 11.1 A shell gets the screen · **the important one for chrome**
|
||
|
||
Open a shell from HOSTS.
|
||
|
||
**Pass:** the vault header, the session strip and the three-entry bottom bar are all gone. What is left is
|
||
one bar — a back arrow, the session pills, a `+` — and then the terminal down to the accessory keys. Press
|
||
back: all three come back, the tab is still in the strip and its dot is still green.
|
||
|
||
**Failure means:** the strip is not bound on `IsShowingPages`, `RefreshChrome` is not reading `HasTabs`, or
|
||
the terminal is being reached by a route that leaves `Surface` on `Page`. A bar still there **with a shell
|
||
showing** is the one that matters: that is the third of the screen this arrangement exists to give back.
|
||
|
||
### 11.2 The connect menu is not drawn over the renderer · **the important one**
|
||
|
||
With a shell showing output, press `+`.
|
||
|
||
**Pass:** the terminal's rectangle goes to the canvas colour and the sheet sits over it whole — scrim, three
|
||
rows and CANCEL, every one of them tappable, none of them sliced down the left edge. Tap the scrim: the
|
||
terminal comes back with its scrollback intact and the shell still running.
|
||
|
||
**Failure means:** `IsTerminalShowing` is not being cleared by `IsConnectSheetOpen` — or, if the sheet is
|
||
sliced *despite* the rectangle going blank, something else in that Panel is still showing. A sheet that
|
||
draws over live terminal output is the Android answer to the compositing question, and it means every
|
||
future sheet on this surface has to collapse the renderer too.
|
||
|
||
### 11.3 Back lowers the menu before it leaves the terminal
|
||
|
||
With the connect menu open, use the system back gesture.
|
||
|
||
**Pass:** the menu closes and the terminal is still showing. A second back leaves the terminal for the
|
||
screen it was opened over.
|
||
|
||
**Failure means:** the guard in `PhoneShell.axaml.cs` is below the surface check rather than above it, and
|
||
one gesture is spending two levels.
|
||
|
||
### 11.4 The two end buttons cannot be pushed off the bar
|
||
|
||
Open six or more shells.
|
||
|
||
**Pass:** the pills scroll under a fixed back arrow and a fixed `+`; neither ever leaves the screen, and
|
||
scrolling the pills to either end does not move them.
|
||
|
||
**Failure means:** a control was put inside the `ScrollViewer` rather than beside it.
|
||
|
||
### 11.5 The text-size buttons are always reachable
|
||
|
||
With a shell open, scroll the accessory key row to the far left and the far right.
|
||
|
||
**Pass:** `A−` and `A+` stay pinned at the right-hand end throughout, separated from the keys by the
|
||
hairline, and both are at least 30 tall. At the smallest and largest sizes the one that can do nothing is
|
||
visibly disabled rather than silently inert.
|
||
|
||
**Failure means:** they have been folded into the scrolling row — which is the arrangement the connection
|
||
line existed to avoid, and the reason it could be removed at all.
|
||
|
||
### 11.6 The keyboard goes away with the connect menu
|
||
|
||
With a shell open, tap into the terminal so the software keyboard comes up, then press `+`.
|
||
|
||
**Pass:** the keyboard drops and the sheet sits on the bottom of the full screen, all four of its rows
|
||
reachable. Repeat on Android 14 or earlier and on 15 or later if you have both — the keyboard shortens this
|
||
head's interface by a different mechanism on each, and a sheet laid out into the strip left above a
|
||
keyboard is the failure this exists to catch.
|
||
|
||
**Failure means:** `SoftKeyboard.Hide` found no activity, no input-method manager, or the wrong window
|
||
token. Note that Avalonia cannot do this for us — `TopLevel.InputPane` reports the keyboard and cannot close
|
||
one, and the keyboard here belongs to the `WebView`, so clearing Avalonia's focus is not a fallback.
|
||
|
||
### 11.7 Connections, with nothing open
|
||
|
||
Close every tab, then press Connections in the bottom bar.
|
||
|
||
**Pass:** a box reading `user@host or user@host:port`, a password box under it, CONNECT, and — if this
|
||
keychain has ever connected to anything — a RECENT list underneath. The whole thing scrolls with the
|
||
keyboard up, and the password box shows dots with **no suggestion strip** above the keyboard.
|
||
|
||
**And the bottom bar is still there, with Connections lit.** Press Hosts: it goes straight there, without
|
||
the back gesture. Then open a shell — the bar goes, which is 11.1. Wide, the same holds for the rail.
|
||
|
||
**Failure means:** for the bar, `RefreshChrome` is back to collapsing the nav for the whole terminal
|
||
surface rather than for a shell — which strands anybody who reaches this screen by closing their last tab.
|
||
For an unlit Connections entry, `IsCurrent` lost its binding: on this screen it is the only true thing the
|
||
bar could say about where you are.
|
||
|
||
Type a machine you can reach and press CONNECT: a tab appears in the bar and the shell opens. Type
|
||
something malformed — no `user@`, or `:70000` — and the refusal appears under the boxes, in the warning
|
||
colour, with no tab and nothing dialled.
|
||
|
||
**Failure means:** for the suggestion strip, the box lost `Classes="secret"` — see 10.1, and it is the same
|
||
seriousness here, since this password is typed rather than stored and so is typed often.
|
||
|
||
### 11.8 A recent machine leads to the right place
|
||
|
||
With at least one keychain host and one machine connected to by hand in the log, open Connections with
|
||
nothing running.
|
||
|
||
**Pass:** tapping the keychain one lands on HOSTS with that host selected **and its connect bar up**.
|
||
Tapping the hand-typed one puts its address back in the box on this screen and leaves the password box
|
||
**empty**. Neither one dials anything on its own.
|
||
|
||
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should
|
||
not have. If the password came back, something is storing it — it never was. And if the keychain one arrives
|
||
with the host selected but **no bar**, `ConnectToRecent` is setting the selection rather than opening the
|
||
pane: since 8.15 a selection alone raises nothing, so that is a screen with nothing to press.
|
||
|
||
### 11.9 Settings holds the keychain, and back walks out of it
|
||
|
||
From HOSTS: Settings, then Keychain.
|
||
|
||
**Pass:** the bottom bar has three entries and the Settings one stays lit while the keychain is showing.
|
||
The keychain draws its own header with a back arrow — the vault name and sync light are not above it. Back,
|
||
by gesture or by the arrow, returns to Settings and not to HOSTS; a second back returns to HOSTS.
|
||
|
||
**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first
|
||
case, and those two have to move together — the switch mirrors that property by construction.
|
||
|
||
### 11.10 The accessory keys do not cost the terminal its keyboard · **needs a hardware keyboard**
|
||
|
||
With a shell open and a Bluetooth or USB keyboard paired, type into the terminal and confirm it arrives.
|
||
Now tap **Tab** on the accessory row, or **A+**, and type again.
|
||
|
||
**Pass:** the byte the key sent arrives, and so does everything typed after it. The terminal still has the
|
||
keyboard.
|
||
|
||
**Failure means:** the buttons on that row have become focusable again. An ordinary Avalonia button takes
|
||
focus on tap, which takes it off the `NativeWebView` — and the package's own `OnLostFocus` then resigns the
|
||
page's focus. The row goes on working, because its keys are pressed rather than typed into, so the symptom
|
||
is a terminal that answers the buttons and ignores the keyboard: it reads as the session having died. See
|
||
`Focusable = false` in `TerminalScreen.axaml.cs` and the focus entry in `docs/platform-flags.md`.
|
||
|
||
Worth doing on the software keyboard too, where the same fault shows as the keyboard closing on the first
|
||
tap of an arrow key.
|
||
|
||
---
|
||
|
||
## Phase 12 — Shared vaults: the operations that span two accounts
|
||
|
||
The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a
|
||
vault is in the way, ownership changes hands, and an address with no account is refused with its own code.
|
||
What is left needs two real accounts, a real sign-in, and in one case a clock — because the half of sharing
|
||
this product is built around happens on a machine rather than on the server, and no server test can reach
|
||
it.
|
||
|
||
**Two accounts, and two profiles.** The dev realm ships `alice` and `bob`; a second DodoSSH profile means a
|
||
second machine, a second OS user, or the same machine after signing out. Whichever account plays the
|
||
newcomer **must not have signed in to this deployment before** — 12.1 is about exactly that boundary.
|
||
|
||
### 12.1 An address with no account is refused, and joins nothing when it later signs in · **the one worth the most care**
|
||
|
||
1. Sign in as `alice`, make a vault on the VAULTS screen, and select it.
|
||
2. Add `bob@example.com` as a Member, with Bob having never signed in here.
|
||
3. **Pass:** it is refused. The status line names the address and says to ask them to sign in to this
|
||
server once and then add them. **Nothing on the screen should suggest anything is pending** — no
|
||
invited row, no "we will add them when they arrive", and the address stays in the box so it can be
|
||
used again in a moment.
|
||
4. Sign in as `bob` on the second profile and enroll.
|
||
5. **Pass, and this is the half that is easiest to lose:** Bob's vault list holds only his personal vault.
|
||
Alice's members table is unchanged. Signing in with an address somebody typed earlier must join nothing
|
||
at all.
|
||
6. Back on Alice's machine, add `bob@example.com` again.
|
||
7. **Pass:** he is added, and — because Alice's machine holds the vault key and Bob has now published one
|
||
— the key is wrapped in the same step. Have Bob sync; the vault opens.
|
||
|
||
**Failure means:** step 5 is the one to stop on. A vault appearing in Bob's list because he signed in with
|
||
an address is the deferred-membership path coming back, and it is the property this phase exists to check:
|
||
access is granted to an account somebody named, never to an address. See ADR 0009.
|
||
|
||
Step 7 opening the vault *without* Alice's machine having wrapped a key would be the more serious failure:
|
||
nothing on the server can wrap a vault key, so an item that decrypts after a membership change alone means
|
||
a key reached that machine by a route this architecture says does not exist.
|
||
|
||
### 12.2 Somebody who has signed in but not enrolled can still be added
|
||
|
||
Have a third account sign in and stop — no passphrase, no enrollment. Then add its address to a vault.
|
||
|
||
**Pass:** accepted. Their row appears saying they hold no key, and SHARE KEY does not offer to wrap one to
|
||
them. The directory returns nothing for that address, which is correct: it lists accounts that have
|
||
published a key, and this one has not.
|
||
|
||
**Failure means:** a refusal here is the old bug, and it is the reason ADD does not take the directory's
|
||
silence as an answer. Everybody passes through the window between a first sign-in and enrollment, and
|
||
being told they have no account while they are standing beside you is the worst moment to say it.
|
||
|
||
### 12.3 An address already in the team is refused, and says which
|
||
|
||
With Bob in the vault, add `bob@example.com` to it again.
|
||
|
||
**Pass:** refused, with a sentence saying the address already belongs to a member and to change their role
|
||
instead — distinct from the "no account here" refusal in 12.1, because the two lead somewhere different.
|
||
|
||
**Failure means:** the two refusals reading alike leaves somebody checking what they typed when the answer
|
||
is that the person is already in.
|
||
|
||
### 12.4 Somebody can be added from the phone, and the key goes with them · **needs a device and two accounts**
|
||
|
||
On the phone: Settings → Vaults, choose a shared vault this account administers.
|
||
|
||
**Pass:** under the members list there is an ADD SOMEBODY box, three role chips with MEMBER lit, an ADD
|
||
button, and a paragraph saying that adding lets the server serve them and does not let them read anything.
|
||
On a vault this account only belongs to, none of that is drawn at all — not greyed, absent.
|
||
|
||
Type a colleague's address and press ADD, with the phone unlocked.
|
||
|
||
**Pass:** they appear in the members list, and the status line says both halves — that they were added, and
|
||
what happened to the key. On an unlocked phone that holds the vault key it says it shared it; the colleague
|
||
syncs and the vault opens.
|
||
|
||
Now lock the phone's keychain and add somebody else.
|
||
|
||
**Pass:** it refuses to pretend. The membership is made and the line says the key could not be wrapped
|
||
because this keychain is locked — which is a state somebody can act on, unlike silence.
|
||
|
||
**Failure means:** a screen that adds somebody and says nothing about the key is implying the server can
|
||
hand out access, which is the one claim this product exists to refuse. ADD drawn for a plain member is a
|
||
button whose only outcome is a 403 from the server.
|
||
|
||
### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
|
||
|
||
Use one account and leave the other idle for two or three hours, then read the members table.
|
||
|
||
**Pass:** the account being used carries a recent time, the idle one does not move, and neither moves more
|
||
than once an hour however much is done in it. It is shown as roughly-when, never to the minute.
|
||
|
||
**Failure means:** a value that tracks every click means the hourly gate is gone and every authenticated
|
||
request is writing to `user_account` — which carries the xmin concurrency token, so the next symptom is a
|
||
user's own overlapping requests failing on a version that moved under them. A value frozen at enrollment
|
||
means the refresh is not running on ordinary requests at all, which is the state that made this column
|
||
impossible to offer honestly before.
|
||
|
||
### 12.6 Ownership changes hands in one act
|
||
|
||
As the owner, transfer ownership to another active member, then read both rows.
|
||
|
||
**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grant is intact
|
||
and the vault has not come back flagged for rekey. Then try to hand it to somebody who is not a member, and
|
||
to yourself.
|
||
|
||
**Pass:** both refused, and the message says which.
|
||
|
||
**Failure means:** two owners, or none, is the state this being a single transaction exists to prevent, and
|
||
either one leaves a vault that no client can administer back into shape. If your grant was revoked or the
|
||
vault is now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them.
|
||
|
||
### 12.7 A vault cannot be deleted, and the screen says so rather than offering a button
|
||
|
||
Look for a way to remove a vault, on both heads.
|
||
|
||
**Pass:** there is none, and the VAULTS screen says why in a sentence: nothing in this product removes a
|
||
vault, and the server refuses to archive the membership list behind one while it exists. Archiving that
|
||
list is still reachable over the API, and the endpoint suite drives both its refusal and its success — what
|
||
is being checked here is that no button offers it.
|
||
|
||
**Failure means:** a delete that worked would take the vault out of the list of everybody holding a key —
|
||
including the person who pressed it, quietly, and with nothing in the product able to put them back. A
|
||
button that always refuses is the milder failure and is still worth removing.
|
||
|
||
### 12.8 Renaming a vault reaches every place its name is drawn · **needs two accounts**
|
||
|
||
Rename a shared vault from the VAULTS screen.
|
||
|
||
**Pass:** the new name is on the vault list, on the badge of every host card in that vault, in the keychain
|
||
screen's "new items go to" picker, in the host editor's vault picker, and in the tab strip's vault menu —
|
||
and on the second account after a refresh. Nothing in the vault needs re-encrypting and everybody's key
|
||
still opens it.
|
||
|
||
**Failure means:** a name that moved in one place and not another is the shape this rename is most likely to
|
||
fail in, because several screens read it separately from a cached vault row. A vault that stops opening
|
||
after a rename would be far worse, and means that cached row was replaced by the server's answer rather
|
||
than edited — that answer deliberately carries no wrapped key.
|
||
|
||
### 12.9 Somebody who may write to a vault may not rename it
|
||
|
||
As a plain Member of somebody else's vault, look for RENAME.
|
||
|
||
**Pass:** it is not drawn. Adding a host to that vault still works, which is what makes this a boundary
|
||
rather than a broken role.
|
||
|
||
**Failure means:** a name is what everybody in the vault sees it called, so a member renaming it out from
|
||
under the people who share it is an administrative act reached without the role for it. The server refuses
|
||
it too — this is the interface not offering what the server would turn down.
|
||
|
||
### 12.10 A group made in a shared vault arrives as a group, not as a heap · **needs two accounts**
|
||
|
||
1. As Alice, on HOSTS, press + NEW GROUP, choose the shared vault in the editor's VAULT picker, name it
|
||
`production`, and give it a default port and username.
|
||
2. Add two hosts to the same shared vault and file them under it.
|
||
3. Make a second group called `production` in the **personal** vault.
|
||
4. Sync, then look at Bob's machine after his own sync.
|
||
|
||
**Pass on Alice's:** the two cards are told apart by the vault name printed under each — same name, two
|
||
folders — and on the phone the two headings carry the same badge. Opening either shows only its own hosts.
|
||
Dragging one of the shared vault's host cards onto the personal `production` card is **refused with a
|
||
sentence naming both vaults**, and the host stays where it was.
|
||
|
||
**Pass on Bob's:** the group is a card and a heading on his machine too, with the hosts inside it, and the
|
||
port and username they dial are the ones Alice typed into the group rather than 22 and his own account. He
|
||
can rename it, and the rename comes back to Alice rather than arriving as a second group in his personal
|
||
vault.
|
||
|
||
**Failure means:** a group that reaches Bob as UNGROUPED hosts is the resolution map having gone narrow
|
||
again — cosmetic on its own, except that the port and the username go with it, so his terminal dials the
|
||
wrong place. A rename of his that turns up as a new group in his own vault is the editor writing to the
|
||
active vault rather than to the row's, which forks the shelf and leaves Alice's untouched. Two identical
|
||
cards with no vault under them means one of them is a folder somebody outside the team can read, and
|
||
nothing on screen says which.
|
||
|
||
---
|
||
|
||
## Phase 13 — Unlocking the phone with a fingerprint
|
||
|
||
Every check here needs a real Android device or emulator with a screen lock and a fingerprint enrolled on
|
||
the phone itself, and none has a headless equivalent: the whole feature is a keystore key the platform will
|
||
not release without a gesture, and there is no gesture in a test process. What *is* covered automatically is
|
||
the shell's half — `ShellFlowTests` registers, relaunches, unlocks and withdraws against a fake keystore, so
|
||
what is left here is the platform half plus the one thing only a person can see, which is which dialogue
|
||
comes up.
|
||
|
||
### 13.1 The offer is on PREFERENCES, and only when there is something to offer
|
||
|
||
Unlock the keychain, go to SETTINGS → Preferences on a phone with a screen lock.
|
||
|
||
**Pass:** REGISTER THIS PHONE is there under THIS PHONE. On a phone with **no** screen lock at all, neither
|
||
button is drawn and the paragraph saying this phone has nowhere to keep a device key is.
|
||
|
||
**Failure means:** if the button is drawn on a phone with no screen lock, `AndroidDeviceKeyStore`
|
||
`IsAvailableAsync` is no longer asking the keyguard — and registering there would put a wrap on the account
|
||
that nothing can ever open, on a phone whose key no gesture can release.
|
||
|
||
### 13.2 Registering asks for the fingerprint, and says which phone it registered
|
||
|
||
Press REGISTER THIS PHONE while signed in.
|
||
|
||
**Pass:** the system's own biometric prompt appears, titled "Register this phone". Confirm it, and the status
|
||
line names **this phone** — the name from Android's Settings, or the model — rather than `localhost`. The
|
||
button is replaced by STOP UNLOCKING HERE. Cancel the prompt instead and nothing changes but the message.
|
||
|
||
**Failure means:** a status line reading `localhost` means the head is no longer passing
|
||
`PhoneEnvironment.DeviceName`, and the account's device list is about to fill with rows nobody can tell
|
||
apart. No prompt at all means the cipher is not being bound to it — see `BiometricGate`, where binding is
|
||
the entire point.
|
||
|
||
### 13.3 The lock screen then opens without the passphrase
|
||
|
||
Lock the keychain, close the app, and launch it again.
|
||
|
||
**Pass:** the prompt is raised on arrival, and confirming it opens the keychain with nothing typed. UNLOCK
|
||
WITH FINGERPRINT is on the screen behind it. Lock from inside the running app instead and **no** prompt is
|
||
raised — that rule is deliberate; see `PhoneShell.TryOfferDeviceUnlock`.
|
||
|
||
**Failure means:** a button that is absent after a successful registration is the wrap not reaching the local
|
||
cache. A prompt raised after an in-app lock trains the reflex of authenticating at a prompt nobody asked for.
|
||
|
||
### 13.4 Enrolling a new fingerprint on the phone destroys the key · **the security property**
|
||
|
||
With DodoSSH registered, add another fingerprint in Android's own Settings. Then launch DodoSSH.
|
||
|
||
**Pass:** no fingerprint button, and the passphrase opens the vault as it always did. Registering again from
|
||
PREFERENCES restores it.
|
||
|
||
**Failure means:** `setInvalidatedByBiometricEnrollment` has been dropped, and anybody who can add their own
|
||
fingerprint to an unlocked phone has inherited the vault. This is the check that says the phone's fast path
|
||
is not a downgrade of the passphrase.
|
||
|
||
### 13.5 Withdrawing stops this phone, and clears the account
|
||
|
||
Press STOP UNLOCKING HERE.
|
||
|
||
**Pass:** it goes back to offering REGISTER, a relaunch asks for the passphrase, and the device is gone from
|
||
the account — check from the desktop head, or by registering the same phone again and seeing one device
|
||
rather than two. There is no confirmation prompt, deliberately.
|
||
|
||
**Failure means:** a phone that still unlocks itself after this is the local half not happening, which is the
|
||
half that matters when the handset is the thing that was lost.
|
||
|
||
### 13.6 The phone says which build it is
|
||
|
||
Scroll the same screen to **THIS BUILD**.
|
||
|
||
**Pass:** a VERSION row carrying a real version rather than `1.0.0`, matching the tag the APK was built
|
||
from, with the paragraph under it saying this head does not replace itself and that no DodoSSH server will
|
||
ever offer you a newer one.
|
||
|
||
**Failure means:** `1.0.0` is MinVer answering from a checkout with no tags — see check 16.5 for the same
|
||
failure on the desktop. A missing paragraph means `Updates.IsUnsupported` came back false on a head that
|
||
has no updater at all, which would be the null channel not being the one composed.
|
||
|
||
---
|
||
|
||
## Phase 14 — Moving files to and from the phone's remote
|
||
|
||
Every check here needs a real Android device or emulator, a host with SFTP or a bucket, and at least one
|
||
document on the phone to send. What is automated is what can be: `TransferQueueingTests` says a staged
|
||
upload obeys the same rules as any other and that a delivered download refuses a directory before the
|
||
picker's damage is done. What cannot be automated is everything below — the two system pickers are another
|
||
application, and the staging copies, the delivery, the notification and the resume are all things only a
|
||
running phone does.
|
||
|
||
### 14.1 ADD FILES opens the system picker, and takes more than one
|
||
|
||
Connect to a host on SFTP, navigate somewhere writable, press **ADD FILES**, and choose two documents in one
|
||
go — long-press to multi-select in Android's picker.
|
||
|
||
**Pass:** two rows appear in the queue with the names the picker showed, and both land in the directory the
|
||
breadcrumb names. The pane's listing shows them after **↻**.
|
||
|
||
**Failure means:** one row from a two-document pick is `PickAsync` losing the rest, and two rows with one
|
||
name is the per-file staging directory having gone — that is the overwrite `DocumentStaging` documents, and
|
||
it silently uploads the same bytes twice.
|
||
|
||
### 14.2 The name that arrives is the name that was picked · **the one most likely to be wrong**
|
||
|
||
Pick a document whose display name has a space and a non-Latin character in it, and one from a cloud
|
||
provider — Drive, or the Downloads shortcut — rather than local storage.
|
||
|
||
**Pass:** the file on the host is called what the picker called it. A cloud document uploads too, or fails
|
||
with the provider's own message in the status line rather than a crash.
|
||
|
||
**Failure means:** a mangled name is `SafeName` over-reaching. A name that reaches the host with a `/` in it
|
||
is `SafeName` under-reaching, and that one writes to a path nobody chose. A cloud document that hangs is
|
||
the copy being made on the interface thread — the whole reason `CopyInAsync` leaves it.
|
||
|
||
### 14.3 The queue is bounded, and the buttons stay reachable
|
||
|
||
Queue five or six files at once, on a small phone if there is one.
|
||
|
||
**Pass:** the queue scrolls inside its own region and **ADD FILES**, **DELETE** and **CLOSE** are all still
|
||
on screen. Every button is a thumb's size.
|
||
|
||
**Failure means:** buttons pushed off the bottom is the `MaxHeight` gone from the queue's scroller, and it
|
||
makes the screen unusable exactly when somebody has queued the most work.
|
||
|
||
### 14.4 A stopped upload resumes rather than starting again
|
||
|
||
Start a large upload, press **STOP** part way, then press **RESUME**.
|
||
|
||
**Pass:** it carries on from roughly where it stopped rather than from zero — the progress text is the thing
|
||
to read.
|
||
|
||
**Failure means:** restarting from zero means the staged copy was deleted at the stop, which is precisely
|
||
what `QueueStagedUploads` does not do and why it does not. A failure saying the file cannot be found is the
|
||
same bug, one step further along.
|
||
|
||
### 14.5 The copies do not accumulate · **the one nothing else would catch**
|
||
|
||
Note the app's storage in Android Settings → Apps → DodoSSH → Storage. Upload a large file, let it finish,
|
||
and look again. Then stop an upload part way, leave it stopped, force-stop the app and relaunch it.
|
||
|
||
**Pass:** storage returns to about what it was after the successful upload — the copy is deleted the moment
|
||
the transfer completes. After the stopped one, the cache is bigger while the app stays open (the copy is
|
||
being kept for RESUME) and back to its old size after the relaunch, which is `DocumentStaging.Sweep`.
|
||
|
||
**Failure means:** growth after a successful upload is `ReleaseStaged` not firing, and every file sent
|
||
leaves a second copy on the phone until Android reclaims the cache. Growth that survives a relaunch is the
|
||
sweep not running.
|
||
|
||
### 14.6 The notification is up while it transfers, and gone afterwards
|
||
|
||
Queue several files in each direction, put the phone to sleep with the screen off, and wait.
|
||
|
||
**Pass:** the foreground notification is up, the transfers finish while the screen is off, and the
|
||
notification goes away when the last one does — with no shell open. With a shell open it stays, because that
|
||
is what it was already for.
|
||
|
||
**Failure means:** an upload that stalls with the screen off is the count not reaching
|
||
`SessionForegroundService`, and Android has stopped the process mid-transfer. A notification left up
|
||
afterwards is `ActivityChanged` not being subscribed — the other end of the same wire.
|
||
|
||
### 14.7 SAVE FILE writes where you pointed it, and the file opens
|
||
|
||
Select a file on the host — something with a viewer, an image or a PDF — press **SAVE FILE**, and put it
|
||
somewhere reachable: Downloads, or a folder in Drive. When the transfer finishes, open it from the phone's
|
||
own Files app.
|
||
|
||
**Pass:** the status line says it was saved, the file is where the picker was pointed under the name shown
|
||
there, and it opens with the right contents. The queue row says DONE.
|
||
|
||
**Failure means:** a row that says DONE with nothing at the destination is `DeliverAsync` never running —
|
||
the delivery is registered per transfer id, and losing it makes the download look like a success while the
|
||
bytes sit in a cache nobody can reach. A file that is there but empty or truncated is the copy out, not the
|
||
transfer: check the `SetLength(0)` and that the write stream is being disposed before the status is written.
|
||
|
||
### 14.8 The button is dead until a file is chosen, and refuses a directory
|
||
|
||
With nothing selected, look at **SAVE FILE**. Then select a directory row.
|
||
|
||
**Pass:** disabled in both cases — it needs a connected remote and a selected *file*, which is the desktop's
|
||
own `CanDownload`.
|
||
|
||
**Failure means:** an enabled button over a directory reaches `QueueDeliveredDownload`'s refusal, which is
|
||
the right answer arriving too late: the save picker has already created an empty file, so the person is left
|
||
with a file they did not want and a message saying nothing happened.
|
||
|
||
### 14.9 A download that fails leaves the empty file it warned about
|
||
|
||
Point SAVE FILE at a destination for a large file, then break the transfer — turn off Wi-Fi and mobile data
|
||
while it runs.
|
||
|
||
**Pass:** the row goes to FAILED with the reason, the status line does not claim it was saved, and there is
|
||
an empty file at the destination. Reconnect, press **RETRY**, and the same destination fills in — the
|
||
delivery survives the failure because it is held against the transfer rather than the attempt.
|
||
|
||
**Failure means:** a retry that succeeds but leaves the destination empty is the delivery having been
|
||
dropped on the failure. An error saying the staged file is missing is the copy having been deleted at the
|
||
stop, which is what `QueueDeliveredDownload` documents it does not do.
|
||
|
||
## Phase 15 — Changes that arrive without a timer
|
||
|
||
The socket is covered by tests on both sides: the endpoint suite opens a real one against a real
|
||
`TestServer` and proves a push produces a notice, that another account's push does not, and that a frame
|
||
carries no ciphertext; the shell suite proves a notice wakes the synchronisation loop long before the
|
||
minute. What none of that can reach is **the network in between**, and that is where this feature is most
|
||
likely to fail: a reverse proxy that will not upgrade, one that drops an idle socket without telling either
|
||
end, a corporate middlebox, a phone moving between Wi-Fi and mobile data. Every one of those looks the same
|
||
from inside a test host, which has no proxy and no radio.
|
||
|
||
The pass condition throughout is *two* things, and the second matters as much as the first: it arrives
|
||
quickly, **and** it still arrives when the socket is gone. A build where the timer had stopped working would
|
||
pass every "it was fast" check here and fail nobody until somebody's proxy changed.
|
||
|
||
### 15.1 A colleague's edit appears while you are looking at it
|
||
|
||
Two accounts sharing a vault, both unlocked, both on the Hosts screen. On the first machine, rename a host
|
||
in the shared vault and save.
|
||
|
||
**Pass:** the second machine's list shows the new name within a second or two, with nothing pressed and no
|
||
screen flicker — the row updates, the selection does not move, and the status line is not repainted with a
|
||
sync report.
|
||
|
||
**Failure means:** nothing within a minute, then the new name, is the socket not being established at all —
|
||
that is the timer doing its job, which is the correct fallback and not the feature. Check `/api/v1/meta`
|
||
lists `events`, then whether the proxy in front of the API forwards `Upgrade` and `Connection`. A list that
|
||
never updates at all is a synchronisation failure and has nothing to do with this phase.
|
||
|
||
### 15.2 A vault shared with you turns up as it is shared
|
||
|
||
The second account signed in and unlocked, sitting on the VAULTS screen. From the first, add them to a team
|
||
and press SHARE KEY.
|
||
|
||
**Pass:** the vault appears in their list within a second or two of the key being wrapped, and reads as
|
||
waiting for a key until the share, then as readable.
|
||
|
||
**Failure means:** the vault appearing only on the minute is the `vaults.changed` notice not being published
|
||
or not being followed. Both the membership add and the grant publish one; if the membership arrives promptly
|
||
and the key does not, the grant path is the one to look at.
|
||
|
||
### 15.3 It still works with the socket taken away
|
||
|
||
On the second machine, block the WebSocket — the simplest way is a proxy rule rejecting the upgrade, or
|
||
setting `Events:Enabled` to `false` on the server and restarting it.
|
||
|
||
**Pass:** everything above still happens, within the minute rather than within seconds. Nothing on the
|
||
screen says anything is wrong, because nothing is: no error, no OFFLINE badge, no repeated status message.
|
||
The Sync button still works and still reports.
|
||
|
||
**Failure means:** an error message, a titlebar claiming to be offline, or a status line that repaints with
|
||
a socket failure is the client treating an absent push channel as a fault. It is not one — the timer is the
|
||
guarantee and the socket is the optimisation, and a user with a strict proxy must never be told their
|
||
keychain is broken.
|
||
|
||
### 15.4 A laptop that slept comes back on its own
|
||
|
||
With the second machine idle and connected, close the lid for a few minutes — or disable Wi-Fi for two
|
||
minutes and re-enable it. Then make a change on the first machine.
|
||
|
||
**Pass:** the change arrives quickly again, without the vault having been locked or the application
|
||
restarted. The reconnection is invisible.
|
||
|
||
**Failure means:** changes that arrive only on the timer from then on are the stream having given up after
|
||
its socket died — the reconnection loop is what should make that impossible, and a client that reconnects
|
||
once and not twice is the specific defect its tests exist to catch. Changes that never arrive again, timer
|
||
included, are a different and worse bug in the synchronisation loop rather than in the socket.
|
||
|
||
### 15.5 An expiring token does not end the push
|
||
|
||
This one needs a short access-token lifetime in the identity provider — the dev realm's Keycloak client can
|
||
be set to a couple of minutes. Leave a machine unlocked and idle for longer than that, then make a change
|
||
elsewhere.
|
||
|
||
**Pass:** the change still arrives quickly. The socket is closed by the server at the token's expiry and the
|
||
client reconnects with a fresh one, which should be invisible.
|
||
|
||
**Failure means:** notices stopping at roughly the token's lifetime is the reconnection not asking for a new
|
||
token — it would be dialling with the spent one and being closed again immediately. A burst of reconnection
|
||
attempts in the server log is the same defect seen from the other end.
|
||
---
|
||
|
||
## Phase 16 — Installing the desktop client, and being updated by it
|
||
|
||
Nothing in this phase is reachable by a test, and not for the usual reason. There is no installed
|
||
application in CI, no `%LOCALAPPDATA%` worth inspecting, and the update path only exists across two builds
|
||
published minutes apart — so what is being checked is the shape of a release, which only exists once
|
||
somebody has cut one. What *is* automated is the machinery underneath: `UpdateFlowTests` drives every state
|
||
of the view model against a fake channel, and pins the one promise that matters
|
||
(`AReadyUpdate_IsNeverAppliedOnItsOwn`); `UpdateBannerTests` measures the banner at the window's minimum
|
||
width. Neither can install anything.
|
||
|
||
Walk it once per release, and in order — 16.6 onwards needs 16.1 to have happened.
|
||
|
||
Run `pwsh -File scripts/release-windows.ps1` first. It stops after packing, on purpose, so that everything
|
||
below happens before anything reaches a user.
|
||
|
||
### 16.0 The feed is readable without credentials · **do this one first, on either platform**
|
||
|
||
Nothing else in this phase or the next means anything until this passes. Run it from anywhere, signed in to
|
||
nothing:
|
||
|
||
```bash
|
||
curl -so /dev/null -w '%{http_code}\n' https://git.dodotech.cloud/api/v1/repos/DodoTech-Public/DodoSSH
|
||
```
|
||
|
||
**Pass:** `200`.
|
||
|
||
**Failure means:** `404` is a repository this caller may not see — Gitea does not distinguish "not there"
|
||
from "not yours" — and the clients read this address anonymously, because there is no token and deliberately
|
||
nowhere to put one. Every check on every machine will answer 404 and the feature cannot work. See
|
||
[ADR 0013 §4](adr/0013-desktop-distribution-and-updates.md).
|
||
|
||
**And check the organisation before the repository, because the organisation wins.** A Gitea org has its own
|
||
visibility, and a *public repository inside a Limited or Private org is invisible to anyone not signed in* —
|
||
so setting the repository public and stopping there changes nothing. This is what it looks like:
|
||
|
||
```bash
|
||
curl -so /dev/null -w '%{http_code}
|
||
' https://git.dodotech.cloud/api/v1/orgs/DodoTech
|
||
```
|
||
|
||
`404` there means the org is the gate; `200` means it is not and the repository itself is. Both must answer
|
||
`200`. One more, which tells the two apart from an instance that requires sign-in for everything:
|
||
`/explore/repos` answering `200` means anonymous browsing is on, so a repo that is still invisible is
|
||
invisible on purpose rather than by instance policy.
|
||
|
||
**Do not check `/api/v1/version` instead.** It answers `200` from a forge that is up regardless of what is
|
||
readable on it, and that is exactly what made this look fine while nothing worked.
|
||
|
||
### 16.1 The installer needs no administrator, and lands beside the vault rather than on it · **the one that would destroy data**
|
||
|
||
Run `Releases\DodoSSH.Desktop-win-Setup.exe` from an ordinary account. Then look at `%LOCALAPPDATA%`.
|
||
|
||
**Pass:** no UAC prompt; `%LOCALAPPDATA%\DodoSSH.Desktop\current\DodoSSH.exe` exists; a Start-menu entry
|
||
reading **DodoSSH**; and `%LOCALAPPDATA%\DodoSSH` either absent (a fresh machine) or **untouched**.
|
||
|
||
**Failure means:** a UAC prompt is a per-machine install, which is not what was designed. Anything written
|
||
into `%LOCALAPPDATA%\DodoSSH` is the pack id having drifted back to `DodoSSH`, and that is the serious one —
|
||
the uninstaller removes its whole install root, so it would take the vault cache and the outbox with it.
|
||
See [ADR 0013](adr/0013-desktop-distribution-and-updates.md) decision 2.
|
||
|
||
### 16.2 The installed path is short, measured rather than assumed
|
||
|
||
```powershell
|
||
"$env:LOCALAPPDATA\DodoSSH.Desktop\current\DodoSSH.exe".Length
|
||
```
|
||
|
||
**Pass:** under 100. It is 64 on an ordinary profile.
|
||
|
||
**Failure means:** folder redirection, or a very long profile path. The terminal is about to fail with
|
||
`CO_E_SERVER_EXEC_FAILURE` and name nothing — see the long-path entry in `platform-flags.md`, which is the
|
||
reason this check is a number rather than a shrug.
|
||
|
||
### 16.3 The window opens and carries its own icon
|
||
|
||
**Pass:** the Start-menu shortcut launches it, and the taskbar and Alt-Tab show the dodo mark rather than a
|
||
generic icon.
|
||
|
||
**Failure means:** `--icon` or `ApplicationIcon` did not survive packaging. Cosmetic, and the first thing
|
||
anybody notices.
|
||
|
||
### 16.4 A terminal connects from the installed build · **the one that would catch an AppContainer**
|
||
|
||
Sign in, unlock, open a shell against a real host, and type.
|
||
|
||
**Pass:** characters reach the remote and output comes back.
|
||
|
||
**Failure means:** if it hangs and then reports the WebView2 message after about fifteen seconds, the
|
||
renderer never attached — check whether packaging has given the process a package identity, which would put
|
||
WebView2 in an AppContainer where the loopback data plane cannot connect. That is the failure MSIX was ruled
|
||
out for, and this is the check that would find it in the Velopack path. `RendererTimeout` is where the
|
||
fifteen seconds comes from.
|
||
|
||
### 16.5 The version on screen is the version that was built
|
||
|
||
Right-click `DodoSSH.exe` → Properties → Details, and open PREFERENCES → UPDATES.
|
||
|
||
**Pass:** File version reads the tag (`0.1.0.0`), product **DodoSSH**, company **DodoTech**, and the
|
||
preferences screen prints the same number.
|
||
|
||
**Failure means:** `0.0.0.0` is MinVer never seeing a tag — a shallow clone, or `fetch-depth` having been
|
||
dropped from a checkout. `1.0.0.0` is somebody having wired the app manifest's inert `assemblyIdentity`
|
||
version to the real one. A version on screen that differs from the file properties means the two are being
|
||
read from different places, which is the thing having one number was for.
|
||
|
||
### 16.6 A second release produces a delta, not only a full package
|
||
|
||
Tag `v0.1.1` and run the script again.
|
||
|
||
**Pass:** `Releases\` holds both a `*-full.nupkg` and a `*-delta.nupkg`, and the delta is a small fraction
|
||
of the full.
|
||
|
||
**Failure means:** no delta at all is `vpk download gitea` having found nothing to build one against — the
|
||
previous release did not come down, so every user is about to fetch a ~60 MB full package for a one-line
|
||
change. The
|
||
script warns rather than failing when that is legitimate, which is the first release only.
|
||
|
||
### 16.7 The update arrives, and the restart lands in it · **the whole point of the work**
|
||
|
||
With v0.1.0 installed and running, a vault unlocked, a host change made, and **a terminal open**, publish
|
||
v0.1.1 (`-Upload`). Then press CHECK NOW on PREFERENCES rather than waiting six hours.
|
||
|
||
**Pass:** the progress bar moves, the banner appears above the status bar, and — the part to actually watch
|
||
— the terminal **reflows cleanly rather than being sliced**, with the remote seeing the smaller row count.
|
||
Press **RESTART NOW**: the application closes and reopens as 0.1.1, still enrolled, with the host change
|
||
intact.
|
||
|
||
**Failure means:** no banner is a channel mismatch between `vpk pack --channel` and
|
||
`VelopackUpdateChannel.ReleaseChannel`, which fails silently by design — the check succeeds, finds nothing,
|
||
and reports the client up to date forever. A banner sliced at the terminal's left edge is the occlusion rule
|
||
having been broken, and the fallback is to move the offer into the titlebar instead. Coming back as 0.1.0 is
|
||
the swap having been blocked, usually by a process still holding a file under `current\`. Being asked to
|
||
enrol again means the profile directory did not survive, which is 16.1's failure arriving late.
|
||
|
||
### 16.8 The first connect after an update is not a cold start
|
||
|
||
Immediately after 16.7, connect to a host.
|
||
|
||
**Pass:** the terminal appears about as quickly as it did before the update.
|
||
|
||
**Failure means:** the WebView2 user data folder is back inside `current\` and was destroyed by the update —
|
||
see the entry in `platform-flags.md`. Slow but working, so it gets dismissed as a fluke unless somebody is
|
||
looking for it, which is why it is a numbered check rather than a note.
|
||
|
||
### 16.9 Uninstalling removes the application and leaves the vault · **the data-loss check**
|
||
|
||
Settings → Apps → DodoSSH → Uninstall.
|
||
|
||
**Pass:** `%LOCALAPPDATA%\DodoSSH.Desktop` is gone, and `%LOCALAPPDATA%\DodoSSH` still holds `cache.db`,
|
||
`cache.db-wal` and `cache.db-shm` — all three, per the entry that says any routine touching only the first
|
||
is wrong. Reinstalling then asks for the passphrase rather than for a server.
|
||
|
||
**Failure means:** the cache going with the application is the pack-id collision, and whoever ran this has
|
||
lost their offline unlock and any change that was still in the outbox. That is the failure ADR 0013
|
||
decision 2 exists to prevent, and it is why 16.1 checks the same thing from the other end.
|
||
|
||
### 16.10 The nightly installs beside the release build and not over it · **the one CI cannot check**
|
||
|
||
Needs both: a release build installed by walking 16.1, and `DodoSSH.Desktop.Nightly-win-nightly-Setup.exe`
|
||
from the `nightly-desktop` release. Install the nightly second, and then start **both**.
|
||
|
||
**Pass:** two entries in the Start menu, one *DodoSSH* and one *DodoSSH Nightly*; two directories,
|
||
`%LOCALAPPDATA%\DodoSSH.Desktop` and `%LOCALAPPDATA%\DodoSSH.Desktop.Nightly`; two profiles,
|
||
`%LOCALAPPDATA%\DodoSSH` and `%LOCALAPPDATA%\DodoSSH.Nightly`, each with its own `cache.db` and
|
||
`device.key`. The nightly's titlebar says **DodoSSH Nightly** and the release build's says **DodoSSH** —
|
||
which is the only difference visible while somebody is typing a passphrase into one of them. Signing in to
|
||
the nightly leaves the release build signed in and untouched, and the account shows a second device.
|
||
|
||
**Failure means:** if there is one profile directory, the two builds are sharing a cache and a device key,
|
||
and the next nightly carrying a schema migration will upgrade the database the release build opens. If
|
||
there is one install directory, the pack ids collide and the nightly has replaced the release build
|
||
outright — which is the thing ADR 0013 decision 9 is constructed to make impossible, so it means one of the
|
||
four separations has been undone.
|
||
|
||
**And the direction that matters most:** on the release build, PREFERENCES → UPDATES → CHECK NOW must not
|
||
offer a nightly, ever, however many have been published since. It reads a different index and refuses
|
||
prereleases; if a nightly version is ever offered there, stop and treat it as a release-channel incident
|
||
rather than as a bug in the nightly.
|
||
|
||
---
|
||
|
||
## Phase 17 — Installing and updating the phone
|
||
|
||
Every check here needs a real Android device or emulator. Nothing about this is automated and structurally
|
||
cannot be for the reason phase 8 gives, with one more on top: the install is another application's screen.
|
||
|
||
Two channels, and they are two applications — see [ADR 0014](adr/0014-android-updates.md). The nightly is
|
||
what CI publishes on every push to main and is signed with a key that is in this repository; the release
|
||
channel is cut by `scripts/release-android.ps1` on a machine holding the real key. Most of these checks can
|
||
be walked on the nightly, which is the point of having one.
|
||
|
||
### 17.1 The unknown-sources gate is asked for and not assumed
|
||
|
||
On a phone that has never installed a DodoSSH APK, download one from the release page and open it.
|
||
|
||
**Pass:** Android says the browser is not allowed to install unknown apps and offers the settings screen.
|
||
Granting it there and returning completes the install.
|
||
|
||
**Failure means:** nothing — this is the platform working, and it is the cost ADR 0011 names. It is a check
|
||
because the *next* one depends on somebody having been through it.
|
||
|
||
### 17.2 Both channels install side by side
|
||
|
||
Install a nightly on a phone that already has a release build, or the other way round.
|
||
|
||
**Pass:** two applications, two icons, both openable, each with its own keychain. Android's app info shows
|
||
`dev.dodotech.dodossh` for one and `dev.dodotech.dodossh.nightly` for the other.
|
||
|
||
**Failure means:** if the second install replaces the first, the two channels are sharing a package id and
|
||
the separation ADR 0014 rests on is not there. If it is *refused*, they share an id and differ in key,
|
||
which is the same fault seen from the other side.
|
||
|
||
Both icons say DodoSSH, which is a known defect rather than a surprise — see `docs/platform-flags.md`.
|
||
|
||
### 17.3 The version on screen is the version that was built
|
||
|
||
Settings → Preferences → UPDATES.
|
||
|
||
**Pass:** a version matching the tag it was built from, or on a nightly the full MinVer string with its
|
||
height. Never `1.0.0`.
|
||
|
||
**Failure means:** `1.0.0` is the `_AndroidVersionName` fix having come undone; see platform-flags.
|
||
|
||
### 17.4 A check that finds nothing says so, a check that fails says something else, and a timer says neither
|
||
|
||
**Walk 16.0 first.** This check is the one that used to pass while the whole feature was broken: with the
|
||
repository private, every request answered 404, the channel swallowed it, and CHECK NOW reported the running
|
||
build as the latest. It read exactly like success.
|
||
|
||
Press CHECK NOW on the newest build there is.
|
||
|
||
**Pass:** it answers — "DodoSSH x.y.z is the latest build."
|
||
|
||
Now turn the phone's network off and press CHECK NOW again.
|
||
|
||
**Pass:** it says it could not reach the forge, in whatever words the failure came with, and the section
|
||
shows the failed state rather than a version number. **It must not say the build is the latest.** Those two
|
||
answers being different is the whole of what makes the first one worth reading.
|
||
|
||
Turn the network back on, and leave the app open doing nothing for several minutes.
|
||
|
||
**Pass:** no message appears on its own, either way. A background pass that found nothing — or that could
|
||
not ask — is silent, which is what makes the feature tolerable.
|
||
|
||
**Failure means:** if the offline press reports the latest build, `AndroidUpdateChannel.CheckAsync` is
|
||
catching again. Every outage then looks like good news, which is how this went unnoticed from the day it
|
||
shipped.
|
||
|
||
### 17.5 An update is found, fetched without being asked, and installed only when asked · **the whole point**
|
||
|
||
With an older build installed, publish or wait for a newer one on the same channel, then press CHECK NOW.
|
||
|
||
**Pass:** the bar moves, then an INSTALL button and a line saying what it costs. **Nothing is installed
|
||
yet.** Leave it sitting there and confirm the application still works normally.
|
||
|
||
Press INSTALL.
|
||
|
||
**Pass:** Android's own installer appears naming the package. Agreeing replaces the app and it reopens as
|
||
the new version, still enrolled, with the vault and its hosts intact.
|
||
|
||
**Failure means:** an install that happens without INSTALL being pressed is the policy this feature is built
|
||
around being broken. An `INSTALL_FAILED_UPDATE_INCOMPATIBLE` means the two builds were signed by different
|
||
keys — on the nightly channel that means the committed keystore changed, and on the release channel it means
|
||
the wrong keystore was used.
|
||
|
||
◆ Two failures worth naming separately, because both look like "the button does nothing" and neither is a
|
||
signing problem. **The application closes when INSTALL is pressed** — that is an exception escaping the
|
||
command handler, and the message it should have shown is now the Failed line on this screen. **Nothing
|
||
happens at all, and the application carries on** — Android was asked to install and nobody started the
|
||
confirmation it handed back; see `InstallSessionReceiver` and the ◆ note in ADR 0014 rule 6. This check is
|
||
the only thing in the project that can catch either, which is the argument for running it on a real pair of
|
||
builds rather than reasoning about it.
|
||
|
||
### 17.6 Declining leaves a working session · **the one that would be missed**
|
||
|
||
Repeat 17.5 to the point where Android's installer is on screen, with a terminal open and the vault
|
||
unlocked. Press back or cancel.
|
||
|
||
**Pass:** DodoSSH is still running, still unlocked, and the shell is still connected.
|
||
|
||
**Failure means:** a locked keychain or a dead session is the shell having disposed itself before asking —
|
||
`IUpdateChannel.ApplyingEndsTheProcess` not being read, or answering true on this head. It punishes somebody
|
||
for declining an update, and nothing in an automated suite would notice.
|
||
|
||
### 17.7 A nightly cannot update a release build
|
||
|
||
With a release build installed, download the nightly APK and try to install it *over* it — rename it if the
|
||
package manager will not offer to.
|
||
|
||
**Pass:** refused, or installed as a second application. Never a replacement.
|
||
|
||
**Failure means:** the channels are not separate, and a public key is signing the application people keep
|
||
their credentials in.
|