Files
DodoSSH/docs/manual-checks.md
T
jaap-jan 780f4bf892 Merge branch 'main' into the group's move and its deletion question
Main took the group's EDIT and DELETE off the GROUPS heading while this branch
was adding a MOVE beside them, so the conflict was about the same six pixels
from both directions. Main's answer wins outright, and it is the better one for
the reason its own message gives: a button beside a heading has no card under a
pointer to mean, and had to work its subject out from the selection or from the
trail. Moving a group had that problem worst of all — the thing it takes with it
is everything on the shelf, and "which shelf" is not a question a button there
could answer plainly.

So the MOVE button is gone and the menu entry it was drawn beside is the whole
of it. That entry was already in this branch, above the separator DELETE sits
below, and it needed no change: the card menu selects whatever was right-clicked
before it runs anything, which is exactly the aiming a group move wants.

Three things went with the button. ShowsGroupActions, which main deleted because
hiding buttons was all it did, and which this branch had extended to hide them
for the move panel as well. CanMoveGroupTarget, which existed to answer whether
that button was worth drawing — CanMoveSelectedHost stays, because the phone
really does leave the host's MOVE out rather than offer a refusal, and a menu
whose entries came and went would be a menu whose items move. And the two test
assertions that read them, which were describing the button rather than the
behaviour; what they were guarding is that the two panels never share the
moment, and IsConfirmingGroupDeletion says that directly.

The move panel and the deletion question both keep their place under the
heading, which is where the buttons were and is now simply where that section
puts things. They still exclude each other, by disarming rather than by a
visibility flag: MoveGroup clears a pending deletion and DeleteGroup folds the
move panel away.

Manual checks 3.3 was rewritten by main for the menu and by this branch for the
tick, and now says both; 3.3a is new and walks a two-level shelf across a vault
boundary, which is the half of this feature no headless test can watch land.
2026-08-04 17:11:06 +02:00

1607 lines
85 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.63.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, 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 should ask for a password even
where the config named a key, because **no key material is read**; binding it to a key in the keychain is a
separate act.
---
## 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.10 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.11 Local pane → remote pane
**Pass:** as above. This uses the same platform file format as the Explorer drag, so a failure here with
2.10 passing points at the drag *source*, not the drop target.
### 2.12 Remote pane → local pane
**Pass:** the left pane outlines and the drop queues a download.
### 2.13 Local pane → Explorer
**Pass:** the file copies out.
### 2.14 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.15 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.16 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.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.16 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 hosts on the phone
Nine checks, and the reason there are nine 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.1 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 select 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.2 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.3 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.4 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.5 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.6 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.7 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.8 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.
### 8.9 Every target is a thumb's size
Walk the sheet's two 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.
---
## 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 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.3 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.4 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 four-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:** one of the three rows is not bound on `IsShowingPages`, or the terminal is being reached
by a route that leaves `Surface` on `Page`.
### 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.
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.
### 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.
---
## 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 every branch of the invitation claim is driven with
tokens the test mints itself. That last freedom is exactly what puts this phase here. **A test can mint a
token asserting anything it likes, so it can prove the server's rule and can say nothing whatever about
whether *your* identity provider sends the claim that rule depends on** — and an invitation that never
activates fails by sitting still, which is the failure mode nobody notices. What is left needs two real
accounts, a real sign-in, and in two cases a clock.
**Two accounts, and two profiles.** The dev realm ships `alice` and `bob`, both with verified addresses; a
second DodoSSH profile means a second machine, a second OS user, or the same machine after signing out.
Whichever account plays the invitee **must not have signed in to this deployment before** — most of what
follows is about what happens the first time it does.
### 12.1 An invitation becomes a membership at the invitee's first sign-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. **Nothing is sent, and nothing should look as though it was**
no "invitation emailed", no link to copy, no token anywhere on the screen.
3. **Pass:** the row appears as *pending*, carrying the address, the role and an expiry fourteen days out.
Bob is **not** in the members table, because he has no account here for a membership row to point at.
4. Sign in as `bob` on the second profile and enroll.
5. **Pass:** the vault is in Bob's list the first time he looks, at Member, with nothing further pressed
on either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing,
and Bob is now in the members table.
6. **Pass, and this is the half that is easiest to lose:** the vault is in Bob's list **saying it is
waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it
opens. This is where an invitation differs from an add and the difference is not a defect: adding an
account the directory already knows wraps the key on the spot, because the adding machine has it and
the recipient has a published key to wrap to. An invitation has neither at the moment it is issued —
there is no account yet — and the claim happens on Bob's machine, which holds nothing. So the key is
still owed, and somebody has to hand it over.
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
Step 6 opening the vault *without* Alice sharing a key would be the far 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 An unverified address claims nothing, and the log is the only place that says so
In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in.
Invite that address, then sign in as them.
**Pass:** they get an account and a personal vault and no shared one. The invitation stays *pending* on
the inviter's screen rather than turning into anything, and the API log carries a warning naming how many
invitations it declined to claim. Now set **Email verified** back on. The claim happens on the next request
that crosses the hourly last-seen window, so it is **not** immediate and restarting the client will not
hurry it along — the account already exists, so there is no second first-sign-in to trigger it.
**Failure means:** if the vault appears while the address is unverified, the one security boundary
invitations have is not being enforced, and anybody able to obtain a token asserting a colleague's address
can walk into their vault. Stop there. If it stays pending after verifying, the claim is not reaching the
**access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim
under some other name.
### 12.3 An invitation can be withdrawn until it is taken up
Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address.
**Pass:** the row reads *revoked* and stays on the list rather than disappearing, and the sign-in produces
an ordinary account in no shared vault. Revoking one that has *already* been accepted answers that there
was nothing to withdraw.
**Failure means:** a revoked invitation that still lets somebody in is a removal that did not remove. An
accepted one that could be unpicked here would be worse: it is a membership now, and removing a member
revokes their vault key grants and flags the vault for rekey, which is not what "revoke invitation"
should quietly do.
### 12.4 An address already in the team is refused; an address that merely has an account is not
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. Now make a **second** vault and invite the same address there.
**Pass:** accepted. Bob having an account is deliberately not a reason to refuse — it is claimed within the
hour on his next request rather than at a sign-in, so give it that long before deciding it has not worked.
**Failure means:** if the second invitation is refused because the address already has an account, this
endpoint has become a way of asking the server which addresses have accounts on it, answerable by anybody
willing to create a vault first. See ADR 0009.
### 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.
---
## 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.