Public Access
Two conflicts, and both were two people counting the same things differently rather than disagreeing about what the code should do. PhoneShell's header comment. The branch made "the five hub screens" numberless, because TEAMS made it six and a number in that sentence had already gone stale once. Main corrected "three destinations" to "two" in the same sentence, because giving a shell the whole phone took the terminal out of the set the header is drawn on. Both are right and neither noticed the other: the header now stays on the hub's screens and on the two top-level destinations, which is Hosts and Keychain. The manual checks. Both sides appended a Phase 10 — main added the software keyboard and the phone's terminal surface as 10 and 11, the branch added Teams. Nothing about them overlaps, so the resolution is to keep all three in the order they were written and renumber Teams to Phase 12, its subsections and the one cross-reference inside 12.1 with it. Main's two phases keep the numbers they already carry in its history, since renumbering those would move headings somebody may already have linked to. Everything else merged without a conflict, and the two places worth checking afterwards both held: IsMoreSurface and the first case of PhoneShell.OnBackRequested each kept ShellScreen.Team alongside main's edits. Those two are one fact in two places, so a merge that dropped Team from either would have trapped the user on the teams screen with the MORE tab dark. Verified after resolving: solution builds with no errors and no new warnings, the Android head builds, and every suite passes — App 214, Layout 73, Api 162, Infrastructure 34, Contracts 25, Session 54. App gained the three shell-flow tests main brought with it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1098 lines
54 KiB
Markdown
1098 lines
54 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.
|
||
|
||
---
|
||
|
||
## 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, FILES, KEYS, TEAM, PREFS.
|
||
|
||
**Pass:** each screen draws whole, its buttons all clickable, and the tab strip stays across the top of all
|
||
five.
|
||
|
||
**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.
|
||
|
||
---
|
||
|
||
## 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, a sidebar that now draws headings, and one new frame between the host process and the
|
||
renderer. The data half of all of that is covered: the payloads round-trip, the server refuses the plaintext
|
||
fields, the sidebar's grouping and the snippet policy are in `ShellFlowTests`, and both new screens are
|
||
measured. What is left here is the part that only exists inside a WebView, plus the two-machine cases no
|
||
single-process test can reach.
|
||
|
||
### 3.1 A keychain with no groups looks exactly as it did
|
||
|
||
Open the hosts screen without creating any group.
|
||
|
||
**Pass:** the sidebar list is the flat list of hosts it always was — no headings, no UNGROUPED, nothing
|
||
saying the hosts are unfiled.
|
||
|
||
**Failure means:** the "invisible until used" property is gone, and every existing user gets a heading they
|
||
did not ask for. `RebuildSidebarRows` returns early when `Groups` is empty; that early return is the feature.
|
||
|
||
### 3.2 Filing hosts, and folding a heading
|
||
|
||
Make two groups, file some hosts into each through the host editor, then click a heading.
|
||
|
||
**Pass:** the heading's chevron flips and its hosts disappear; the count on the heading does not change,
|
||
because it counts what is in the group rather than what is on screen. Clicking again brings them back.
|
||
|
||
**Also check:** clicking a heading does not change which host is selected — the buttons at the foot of the
|
||
sidebar go on acting on the same machine. This is asserted in a test, but the test drives the view model
|
||
directly; what it cannot see is whether the `ListBox` writes something else back through the binding first.
|
||
|
||
### 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 reappear under UNGROUPED with everything else about them 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 under UNGROUPED. 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.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 into a group · **least covered, like all drag and drop**
|
||
|
||
Make two groups and file a host into one. Drag a host row onto another group's heading; onto a host row
|
||
inside another group; and onto UNGROUPED.
|
||
|
||
**Pass:** the row under the pointer washes accent while the pointer is over it, the cursor shows a move
|
||
rather than a refusal, and the drop files the host — it moves under that heading and the counts on both
|
||
headings change. Dropping onto its own group's heading is refused while still in the air.
|
||
|
||
**Failure means:** headless Avalonia cannot synthesise a platform drag, so nothing about this gesture is
|
||
automated. The write it performs is: `MovingAHostToAGroup_FilesItAndLeavesItSelected`.
|
||
|
||
### 7.7 A click still selects, and a double click still connects
|
||
|
||
Click host rows; drag one a few pixels without releasing; double-click one.
|
||
|
||
**Pass:** a click selects, a small movement starts nothing, and a double click connects.
|
||
|
||
**Failure means:** the 5-pixel threshold in `HostSidebar.axaml.cs` is not doing its job — the same failure
|
||
as 2.16 on the other screen, and here it would make the list unusable.
|
||
|
||
### 7.8 The highlight clears after a drag that goes nowhere
|
||
|
||
Drag a host over a heading and release outside the list, 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 a group heading.
|
||
|
||
**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 row under the pointer deletes the wrong
|
||
machine. `HostSidebarTests` covers both halves headlessly, so this is a confirmation that a real popup
|
||
behaves as the headless one did.
|
||
|
||
### 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 editor pane scrolls, and FORGET HOST KEY is reachable at the bottom of it.
|
||
|
||
**Failure means:** the pane's MaxHeight is gone or the ScrollViewer is. The layout harness skips anything
|
||
inside a ScrollViewer, so from that commit on it certifies the pane fits rather than the fields — it will
|
||
tell you the pane is fine while the last button sits below the window.
|
||
|
||
### 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 38 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.
|
||
|
||
---
|
||
|
||
## Phase 12 — Teams: 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 team, and open its invitations.
|
||
2. Invite `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 team 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 team's 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.
|
||
|
||
**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 team at all. 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 team 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 team. 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 team. 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 every team 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 team, invite `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** team 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 team 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 grants are intact
|
||
and the team's vaults have not come back flagged for rekey. Then try to transfer 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 team that no client can administer back into shape. If your grants were revoked or the
|
||
vaults are now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them.
|
||
|
||
### 12.7 Archiving is refused while the team owns a vault
|
||
|
||
With a team that owns at least one vault, try to archive it.
|
||
|
||
**Pass:** refused, and the message counts the vaults in the way and says there is no way to delete a vault
|
||
in this product. The team is still in everybody's list afterwards and its vaults still open.
|
||
|
||
**Failure means:** an archive that succeeded here would have taken those vaults 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.
|
||
|
||
### 12.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts**
|
||
|
||
Make a team that owns no vaults, add the second account to it, invite a third address, and archive it.
|
||
|
||
**Pass:** the team is gone from both accounts' lists. Sign in with the invited address afterwards and it
|
||
joins nothing. A new team can be created under the archived one's slug.
|
||
|
||
**Failure means:** the invited address turning up in a team nobody can see is exactly what revoking pending
|
||
invitations inside the same transaction exists to prevent, and it would happen weeks later on a sign-in
|
||
nobody is watching. Note that taking the freed slug is correct rather than a defect, and is also the reason
|
||
an archived team is only restorable by an operator who checks that first.
|
||
|
||
### 12.9 Renaming a team, and the slug that does not move
|
||
|
||
Rename a team and change its description.
|
||
|
||
**Pass:** the new name is on every screen that names the team, on both accounts after a refresh. The slug is
|
||
unchanged and there is nowhere to change it. Nothing claims to know *when* it was renamed.
|
||
|
||
**Failure means:** a rename that moved the slug could take one an archived team is still holding, and that
|
||
archived team could then never be brought back. An "edited" timestamp anywhere on the screen is invented
|
||
data — `team` has no updated-at column, so there is nothing behind it.
|