Public Access
1583 lines
83 KiB
Markdown
1583 lines
83 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, 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 EDIT and DELETE now aim at that group. **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, EDIT and DELETE stay: with no card selected the two buttons act
|
||
on the group the trail ends with, so a group with nothing in it can still be renamed after being opened.
|
||
Pressing the **middle** crumb goes back one level rather than all the way out.
|
||
|
||
**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
|
||
|
||
Select a group with hosts and press DELETE.
|
||
|
||
**Pass:** the question names how many hosts are filed under it and says they stay. Agreeing removes the
|
||
group; the hosts lose their chip and are otherwise unchanged.
|
||
|
||
**Failure means:** if the hosts vanish, the delete is rewriting host payloads, which it must not — see
|
||
`HostGroupRepository`.
|
||
|
||
### 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. That fallback is right for the EDIT and DELETE buttons beside the heading and wrong for a menu that
|
||
opened on a card.
|
||
|
||
### 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.
|