feat(accounts): reference the default token instead of copying it #103
No files matched your search
+25
-9
@@ -244,23 +244,26 @@ the app's account wizard, and use them from either.
|
||||
### `gclo accounts` / `gclo accounts list`
|
||||
|
||||
Lists the saved accounts, one per line, in aligned columns: name, organization,
|
||||
target root, and last sync time (local time, `never` when the account has not
|
||||
completed a sync yet):
|
||||
target root, token source (`own` or `default`), and last sync time (local time,
|
||||
`never` when the account has not completed a sync yet):
|
||||
|
||||
```
|
||||
work contoso C:\src\contoso 2026-07-04 09:12
|
||||
personal octocat D:\mirror never
|
||||
work contoso C:\src\contoso own 2026-07-04 09:12
|
||||
personal octocat D:\mirror default never
|
||||
```
|
||||
|
||||
With `--json` it prints a single-line JSON array with everything a script needs
|
||||
to reason about a run; `lastSync` is the UTC timestamp of the last completed
|
||||
sync and `lastSyncSummary` its summary line, both `null` until the first sync:
|
||||
sync and `lastSyncSummary` its summary line, both `null` until the first sync;
|
||||
`tokenSource` is `own` or `default`:
|
||||
|
||||
```json
|
||||
[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44."}]
|
||||
[{"id":"d0f0e0c09c404a5e8f3a5b1e6f7a2c11","name":"work","description":"","organization":"contoso","targetRoot":"C:\\src\\contoso","createOrgSubfolder":true,"maxConcurrency":8,"lastSync":"2026-07-04T14:12:03+00:00","lastSyncSummary":"Finished: 3 cloned, 41 updated, 0 failed, 0 canceled of 44.","tokenSource":"own"}]
|
||||
```
|
||||
|
||||
`id` is the key of the account's Credential Manager entry (`gclo:account:<id>`).
|
||||
`id` is the key of the account's Credential Manager entry (`gclo:account:<id>`)
|
||||
for an `own` account; a `default` account has no entry of its own and resolves
|
||||
to the default token saved in the desktop app's Settings whenever it syncs.
|
||||
When no accounts exist yet, stdout stays empty (`--json` prints `[]`) and a
|
||||
hint goes to stderr; the exit code is still 0.
|
||||
|
||||
@@ -277,6 +280,16 @@ variable) and is written to Windows Credential Manager — never put it on the
|
||||
command line. A name that another account already uses (case-insensitive)
|
||||
exits with code 2.
|
||||
|
||||
With `--use-default-token` instead of a token option, the account holds no
|
||||
token of its own: it resolves to the default token saved in the desktop app's
|
||||
Settings every time it syncs, so replacing that token in Settings updates every
|
||||
such account at once. The command fails (exit 2) when no default token is saved,
|
||||
because the account could never sync; the CLI cannot set the default token.
|
||||
|
||||
```powershell
|
||||
gclo accounts add --name personal --org octocat --target D:\mirror --use-default-token
|
||||
```
|
||||
|
||||
### `gclo accounts edit`
|
||||
|
||||
```powershell
|
||||
@@ -286,8 +299,11 @@ gclo accounts edit --name work --rename "work (contoso)"
|
||||
```
|
||||
|
||||
`--name` picks the account; every other option replaces just that setting.
|
||||
Without a token option the stored token is left untouched. Editing with
|
||||
nothing to change is a usage error.
|
||||
Without a token option the token (and its source) is left untouched. A
|
||||
`--token-*` option gives the account a token of its own (also switching a
|
||||
`default` account to `own`); `--use-default-token` switches it to the default
|
||||
token and removes its own entry from Credential Manager. Editing with nothing
|
||||
to change is a usage error.
|
||||
|
||||
### `gclo accounts remove`
|
||||
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# UX plan: the default token and account tokens
|
||||
|
||||
> **Status: approved** by the owner on 2026-10-10; step 1 is #104. Delivers #101
|
||||
> (Settings shows a saved default token as a masked value) and #102 (Add account
|
||||
> uses the default token by reference). Tracker of record: GitHub Issues; this
|
||||
> document is the design.
|
||||
|
||||
## The problem, as the owner sees it
|
||||
|
||||
1. In **Settings**, a saved default token leaves the token box empty. An empty box
|
||||
reads as "nothing saved", even with the caption underneath.
|
||||
2. **Add account** always demands a token on step 2, even when a default token is
|
||||
saved. Every account then holds its own copy, so rotating the default token in
|
||||
Settings does nothing for existing accounts: each keeps the old copy until it
|
||||
fails.
|
||||
|
||||
## Design decisions
|
||||
|
||||
- **An account references the default token; it never copies it.** A new field
|
||||
`TokenSource` on the account record: `Default` (use whatever the default token
|
||||
is right now) or `Own` (this account's own vault entry, today's behaviour).
|
||||
Rotating the default token in Settings rotates every `Default` account at once.
|
||||
`Own` accounts are untouched by the default, as today.
|
||||
- **One resolver, used everywhere.** `AccountTokenResolver.Resolve(account)` in
|
||||
`gclo.ViewModels` returns the token to use for an account (`Default` → vault
|
||||
entry `DefaultTokenVaultId`; `Own` → vault entry `account.Id`) plus *why* it is
|
||||
missing when it is. The desktop workspace, Sync All, the wizard's validation
|
||||
and the CLI's `sync --account` all go through it. No consumer reads the vault
|
||||
for an account directly any more.
|
||||
- **The token itself is never shown.** The Settings box shows a fixed mask when a
|
||||
token is saved; the wizard never loads a token into a box; the reveal button
|
||||
of a `PasswordBox` can only ever reveal what the user typed in this session.
|
||||
- **Backward compatible on disk.** `TokenSource` defaults to `Own` when absent, so
|
||||
every existing `accounts.json` and every existing vault entry keeps working.
|
||||
1.0.x builds reading a newer file ignore the field (System.Text.Json default).
|
||||
- **Deleting the default token is a guarded action** when `Default` accounts
|
||||
exist: Settings says which accounts stop working and asks for confirmation.
|
||||
|
||||
## Settings dialog (#101)
|
||||
|
||||
| State | Token box | Caption | Remove link |
|
||||
| --- | --- | --- | --- |
|
||||
| No default token saved | empty, placeholder `ghp_…` | "No default token saved. Quick Sync will ask for one each launch." | hidden |
|
||||
| Default token saved, untouched | shows the mask `••••••••••••••••` (a constant, never the token) | "A default token is saved and used by Quick Sync and *N* account(s). Type to replace it." | visible |
|
||||
| User typed a new value | the typed value (masked) | "The default token will be replaced on Save." | visible |
|
||||
| User cleared the box, or pressed Remove | empty | "The default token will be removed on Save. *N* account(s) use it and will stop syncing until a new one is saved." (plain "…will be removed on Save." when N = 0) | hidden |
|
||||
|
||||
Save semantics: mask unchanged → keep; non-empty and not the mask → store; empty
|
||||
→ remove (only if one was saved). Removing while `Default` accounts exist shows a
|
||||
confirmation ("Remove the default token? These accounts use it: A, B.") before the
|
||||
dialog closes; Cancel returns to the dialog with the mask restored.
|
||||
|
||||
Accessibility: the box keeps its automation id `SettingsDefaultTokenBox`; the
|
||||
mask is announced as the field's value by the password pattern (dots), the
|
||||
caption is a live region, and the Remove link's text names the consequence.
|
||||
|
||||
## Add account / Edit account wizard (#102)
|
||||
|
||||
Step 2 ("Access token") becomes a choice, shown only when a default token is
|
||||
saved; without one the step looks exactly as today.
|
||||
|
||||
```text
|
||||
Step 2 of 4: Access token
|
||||
|
||||
( ) Use the default token from Settings ← selected by default for a new account
|
||||
Used by Quick Sync and N other account(s). Replacing it in Settings updates
|
||||
this account too.
|
||||
( ) Use a different token for this account
|
||||
[ Personal access token ..................... ] (enabled only when selected)
|
||||
How to create a token
|
||||
|
||||
Next validates the chosen token and lists the organizations it can see.
|
||||
```
|
||||
|
||||
- **New account:** "Use the default token" is preselected. Next validates the
|
||||
*default* token through the resolver and lists its organizations. On save the
|
||||
account is written with `TokenSource = Default` and **no vault entry** of its
|
||||
own. Choosing "a different token" requires typing one; save writes
|
||||
`TokenSource = Own` and the entry, as today.
|
||||
- **Edit account:** the radio reflects the current source. Switching `Own →
|
||||
Default` deletes the account's own vault entry on save (confirmed in the
|
||||
caption: "This account's own token will be removed from Credential Manager").
|
||||
Switching `Default → Own` requires a token. Staying on `Own` with the box
|
||||
empty keeps the stored token, as today.
|
||||
- **Default token missing** (removed after the account was created): step 2
|
||||
shows "The default token is no longer saved. Save one in Settings, or use a
|
||||
different token for this account." and Next stays put.
|
||||
- **Seeded wizard** ("save this Quick Sync connection as an account"): if the
|
||||
typed Quick Sync token equals the saved default token, the wizard preselects
|
||||
"Use the default token"; otherwise "a different token" with the typed value.
|
||||
(Comparison happens in memory in the view model; nothing is logged.)
|
||||
|
||||
## Where the account's token shows up elsewhere
|
||||
|
||||
- **Navigation pane / account header:** `Default` accounts get a small
|
||||
"Default token" tag (text, not just an icon) in the account's header, so the
|
||||
dependency on Settings is visible where the account is used.
|
||||
- **Account workspace:** the token box is prefilled from the resolver as today.
|
||||
For a `Default` account its header reads "Using the default token"; editing the
|
||||
box affects this session only, exactly as for Quick Sync.
|
||||
- **Sync All:** a `Default` account whose default token is missing is skipped
|
||||
with the result "No default token saved (Settings)" instead of a generic auth
|
||||
error.
|
||||
- **Error messages:** every "no token" message names the source
|
||||
("the default token in Settings" or "this account's own token") and the fix.
|
||||
|
||||
## CLI parity (`gclo accounts`, `gclo sync --account`)
|
||||
|
||||
- `accounts add … --use-default-token` creates a `Default` account; the existing
|
||||
`--token-env | --token-file | --token-stdin` create `Own` accounts. Exactly one
|
||||
of the four is required (today: one of the three).
|
||||
- `accounts edit --name X --use-default-token` switches to `Default` and removes
|
||||
the own entry; a `--token-*` option switches to `Own`.
|
||||
- `accounts list` gains a `token` column: `default` or `own`; the JSON output
|
||||
gains `"tokenSource"`.
|
||||
- `sync --account X` resolves through the resolver; its "no token" error names
|
||||
the source and the fix (`gclo accounts edit --name X --token-stdin`, or saving
|
||||
a default token in the desktop app's Settings; the CLI gets no way to *set*
|
||||
the default token in this change).
|
||||
- The default token stays a desktop-app concept to set, but the CLI reads it,
|
||||
so a headless mirror can use `Default` accounts created on the same machine.
|
||||
|
||||
## Data model and storage
|
||||
|
||||
- `Account.TokenSource` (`enum TokenSource { Own, Default }`), serialized as a
|
||||
string, default `Own`, `[JsonIgnoreCondition.WhenWritingDefault]` so existing
|
||||
files stay byte-identical until an account actually uses the default.
|
||||
- `AccountsStore.Save(account, token)` keeps its contract; a `Default` account is
|
||||
always saved with `token: null`, and switching to `Default` removes the own
|
||||
entry inside the same vault-first/compensate sequence the store already has.
|
||||
- `AccountsStore.CountUsingDefaultToken()` feeds the Settings captions.
|
||||
- Vault scoping (#60) is unchanged: `DefaultTokenVaultId` already lives in the
|
||||
same scope as the accounts.
|
||||
|
||||
## Security review (threat model T5, T6)
|
||||
|
||||
- No new place holds a token: the mask is a constant; the resolver returns the
|
||||
token to the same callers that already received it; nothing is logged.
|
||||
- A `Default` account widens the blast radius of the default token to every
|
||||
such account by design; the Settings warning on removal and the "Default
|
||||
token" tag make that dependency visible. Rotation becomes a single action,
|
||||
which is the point.
|
||||
- The CLI still has no `--token <value>`; `--use-default-token` carries no
|
||||
secret on the command line.
|
||||
|
||||
## Tests
|
||||
|
||||
- ViewModels (100% line coverage stays enforced): resolver (both sources,
|
||||
missing cases), wizard step 2 for new/edit/seeded in both sources and the
|
||||
missing-default error, store save/switch/compensation with `TokenSource`,
|
||||
settings captions and remove-with-dependents counts (through a small
|
||||
`SettingsTokenState` view model so the dialog has no logic of its own),
|
||||
Sync All skip reason.
|
||||
- CLI (100%): `--use-default-token` on add/edit, mutual exclusion with
|
||||
`--token-*`, list column and JSON field, sync resolution error text.
|
||||
- FlaUI: Settings shows the mask when a token is saved and an empty box when
|
||||
not; Add account shows the radio only when a default token exists, and the
|
||||
token box is disabled while "Use the default token" is selected.
|
||||
- Serialization round-trip: an `accounts.json` without the field loads as `Own`
|
||||
and re-saves without the field.
|
||||
|
||||
## Delivery
|
||||
|
||||
One issue per layer, one PR each, in this order, every PR green on all gates:
|
||||
|
||||
1. **#102a – model + resolver + store + CLI** (no UI change yet; `Default`
|
||||
accounts can only be created by the CLI so far). Threat model updated.
|
||||
2. **#102b – wizard + pane tag + Sync All reason** (desktop).
|
||||
3. **#101 – Settings dialog** (mask, captions, guarded removal), last because its
|
||||
"used by N accounts" count depends on step 1.
|
||||
|
||||
Ships in the next minor (1.1.0): it adds a field to the account record and a
|
||||
CLI option, which is a feature, not a fix; 1.0.2 stays the pipeline/data-root
|
||||
fix release.
|
||||
@@ -87,7 +87,7 @@ flowchart LR
|
||||
| T2 | Package managers / downloads | Tampering | Modified installer or CLI zip on a mirror | Low | High | `SHA256SUMS`; Scoop and Chocolatey manifests pin the hash; "Verifying a release" instructions | Mitigated |
|
||||
| T3 | Git remotes | Tampering / EoP | Repository with Windows-invalid or traversal paths (`..`, reserved names, case collisions) writes outside or corrupts the target | Medium | High | Pre-checkout validation of every incoming path; `RepositoryPathResolver` containment; recovery by rename/skip or clone in WSL; `core.longpaths` | Mitigated |
|
||||
| T4 | Git remotes | Tampering | Hostile `.git` config or hooks in a pre-existing target repository | Low | High | gclo runs no hooks (libgit2, no shell); only fetch and fast-forward; `gclo.checkoutpending` marks incomplete trees | Mitigated |
|
||||
| T5 | PAT handling | Information disclosure | Token leaks to disk, logs, process listings, or UI | Medium | Critical | Memory-only in Quick Sync; Credential Manager otherwise; logs record lengths; no `--token`; WSL via env only; secret scanning + gitleaks + CodeQL in CI | Mitigated |
|
||||
| T5 | PAT handling | Information disclosure | Token leaks to disk, logs, process listings, or UI | Medium | Critical | Memory-only in Quick Sync; Credential Manager otherwise; accounts may reference the default token (`TokenSource`), resolved at use time through one resolver and never copied; logs record lengths; no `--token`; WSL via env only; secret scanning + gitleaks + CodeQL in CI | Mitigated |
|
||||
| T6 | Credential Manager | Information disclosure | Another process as the same user reads the stored token | Medium | High | Documented boundary (SECURITY.md); vault scoped per data directory so test or portable instances never see the default profile's tokens | Accepted (OS boundary) |
|
||||
| T7 | GitHub API | Denial of service | Rate limits or abuse detection stop a sync | Medium | Low | Secondary-rate-limit and abuse handling with backoff; per-repo failure isolation; cancellation | Mitigated |
|
||||
| T8 | GitHub API / remotes | Spoofing | A MITM impersonates GitHub | Low | Critical | HTTPS with system trust store (Octokit, libgit2); no HTTP fallback | Mitigated |
|
||||
@@ -114,3 +114,4 @@ flowchart LR
|
||||
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T11: the packaging PR is opened with a short-lived release GitHub App token (contents + pull requests on this repository, no bypass) instead of a long-lived PAT; the commit is GitHub-signed (#85) |
|
||||
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | Local data directory moved to `%LOCALAPPDATA%\KofTwentyTwo\gclo`: the Velopack install root is deleted by Setup.exe, which destroyed accounts.json and settings.json (#91) |
|
||||
| 2026-10-10 | v1.0.2 | James Maes (@KofTwentyTwo) | T2/T11: releases are Authenticode-signed with Azure Artifact Signing over GitHub OIDC (no signing secret; federated credential bound to this repository's `release` environment; signer role scoped to the certificate profile) and the published assets are verified with `signtool verify /pa /all` (#57, ADR 0008) |
|
||||
| 2026-10-10 | v1.1.0 | James Maes (@KofTwentyTwo) | T5: accounts can reference the default token (`TokenSource = Default`) instead of holding a copy; one `AccountTokenResolver` serves the app and the CLI, so rotating the default token in Settings rotates every such account (#102, docs/plans/default-token-ux.md) |
|
||||
@@ -234,6 +234,144 @@ public sealed class AccountsCommandTests : IDisposable
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- default token (#102)
|
||||
|
||||
[Fact]
|
||||
public void Add_UseDefaultToken_CreatesAnAccountWithoutAnOwnEntry()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = AccountsCommand.Run(
|
||||
["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Account saved = Store.FindByName("shared")!;
|
||||
Assert.True(saved.UsesDefaultToken);
|
||||
Assert.Null(_vault.TryRetrieve(saved.Id));
|
||||
Assert.Contains("uses the default token", console.Out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Add_UseDefaultToken_WithoutASavedDefault_IsAFatalError()
|
||||
{
|
||||
CliErrorException ex = Assert.Throws<CliErrorException>(() => AccountsCommand.Run(
|
||||
["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog()));
|
||||
|
||||
Assert.Contains("No default token is saved", ex.Message);
|
||||
Assert.Contains("Settings", ex.Message);
|
||||
Assert.Empty(Store.GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Add_UseDefaultToken_WithAnotherTokenOption_IsAUsageError()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
using IDisposable _ = TokenEnv("ghp_x");
|
||||
|
||||
Assert.Throws<CliUsageException>(() => AccountsCommand.Run(
|
||||
["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token", "--token-env", EnvVar],
|
||||
Open, new NullLog()));
|
||||
Assert.Empty(Store.GetAll());
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Edit_UseDefaultToken_SwitchesTheAccount_AndRemovesItsOwnToken()
|
||||
{
|
||||
Seed("work", "acme", @"C:\a");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
Guid id = Store.FindByName("work")!.Id;
|
||||
Assert.Equal("tok-work", _vault.TryRetrieve(id));
|
||||
using var console = new ConsoleCapture();
|
||||
|
||||
int code = AccountsCommand.Run(["edit", "--name", "work", "--use-default-token"], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.True(Store.FindByName("work")!.UsesDefaultToken);
|
||||
Assert.Null(_vault.TryRetrieve(id));
|
||||
Assert.Contains("now uses the default token", console.Out);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Edit_UseDefaultToken_WithoutASavedDefault_IsAFatalError_AndKeepsTheOwnToken()
|
||||
{
|
||||
Seed("work", "acme", @"C:\a");
|
||||
Guid id = Store.FindByName("work")!.Id;
|
||||
|
||||
Assert.Throws<CliErrorException>(() => AccountsCommand.Run(["edit", "--name", "work", "--use-default-token"], Open, new NullLog()));
|
||||
|
||||
Assert.False(Store.FindByName("work")!.UsesDefaultToken);
|
||||
Assert.Equal("tok-work", _vault.TryRetrieve(id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Edit_TokenOption_SwitchesADefaultTokenAccountToItsOwnToken()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog());
|
||||
using IDisposable _ = TokenEnv("ghp_mine");
|
||||
|
||||
int code = AccountsCommand.Run(["edit", "--name", "shared", "--token-env", EnvVar], Open, new NullLog());
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Account updated = Store.FindByName("shared")!;
|
||||
Assert.False(updated.UsesDefaultToken);
|
||||
Assert.Equal("ghp_mine", _vault.TryRetrieve(updated.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Edit_OtherSettings_LeaveTheTokenSourceAlone()
|
||||
{
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\src", "--use-default-token"], Open, new NullLog());
|
||||
|
||||
AccountsCommand.Run(["edit", "--name", "shared", "--parallel", "3"], Open, new NullLog());
|
||||
|
||||
Account updated = Store.FindByName("shared")!;
|
||||
Assert.True(updated.UsesDefaultToken);
|
||||
Assert.Equal(3, updated.MaxConcurrency);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void List_ShowsTheTokenSource_InColumnsAndJson()
|
||||
{
|
||||
Seed("work", "acme", @"C:\a");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
AccountsCommand.Run(["add", "--name", "shared", "--org", "acme", "--target", @"C:\b", "--use-default-token"], Open, new NullLog());
|
||||
|
||||
using(var console = new ConsoleCapture())
|
||||
{
|
||||
Assert.Equal(0, AccountsCommand.Run([], Open, new NullLog()));
|
||||
string[] lines = console.Out.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
Assert.Contains(lines, l => l.StartsWith("shared", StringComparison.Ordinal) && l.Contains(" default ", StringComparison.Ordinal));
|
||||
Assert.Contains(lines, l => l.StartsWith("work", StringComparison.Ordinal) && l.Contains(" own ", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
using(var console = new ConsoleCapture())
|
||||
{
|
||||
Assert.Equal(0, AccountsCommand.Run(["--json"], Open, new NullLog()));
|
||||
Assert.Contains("\"name\":\"shared\"", console.Out);
|
||||
Assert.Contains("\"tokenSource\":\"default\"", console.Out);
|
||||
Assert.Contains("\"tokenSource\":\"own\"", console.Out);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Add_UnknownOption_Throws()
|
||||
=> Assert.Throws<CliUsageException>(() => AccountsCommand.Run(["add", "--nope"], Open, new NullLog()));
|
||||
|
||||
@@ -91,12 +91,16 @@ internal sealed class FakeRepoLister : IRepositoryLister
|
||||
|
||||
public Exception? Throw { get; set; }
|
||||
|
||||
/// <summary>The token of the most recent call, so a test can check which source was used.</summary>
|
||||
public string? LastToken { get; private set; }
|
||||
|
||||
|
||||
|
||||
public Task<IReadOnlyList<RepoDescriptor>> ListOrganizationRepositoriesAsync(
|
||||
string organization, string token, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
LastToken = token;
|
||||
return Throw is not null
|
||||
? Task.FromException<IReadOnlyList<RepoDescriptor>>(Throw)
|
||||
: Task.FromResult(Result);
|
||||
|
||||
@@ -579,6 +579,40 @@ public sealed class SyncCommandTests : IDisposable
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Account_UsingTheDefaultToken_SyncsWithIt()
|
||||
{
|
||||
Account account = Seed("shared", "acme");
|
||||
_vault.Delete(account.Id);
|
||||
Store.Save(account with { TokenSource = TokenSource.Default }, null);
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
var lister = new FakeRepoLister { Result = [Repo("a")] };
|
||||
|
||||
int code = await Run(lister, new FakeGit(), "--account", "shared");
|
||||
|
||||
Assert.Equal(0, code);
|
||||
Assert.Equal("ghp_default", lister.LastToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Account_UsingTheDefaultToken_WithoutOne_NamesSettingsInTheError()
|
||||
{
|
||||
Account account = Seed("shared", "acme");
|
||||
_vault.Delete(account.Id);
|
||||
Store.Save(account with { TokenSource = TokenSource.Default }, null);
|
||||
|
||||
CliErrorException ex = await Assert.ThrowsAsync<CliErrorException>(
|
||||
() => Run(new FakeRepoLister(), new FakeGit(), "--account", "shared"));
|
||||
|
||||
Assert.Contains("no default token is saved", ex.Message);
|
||||
Assert.Contains("Settings", ex.Message);
|
||||
Assert.DoesNotContain("gclo:account:", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Account_ExplicitTokenOption_OverridesVault()
|
||||
{
|
||||
|
||||
@@ -26,6 +26,37 @@ public sealed class TokenOptionsTests
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void UseDefaultToken_IsAnExplicitSource_ThatResolvesToNoValue()
|
||||
{
|
||||
TokenOptions options = Parse("--use-default-token");
|
||||
|
||||
Assert.True(options.HasExplicitSource);
|
||||
Assert.True(options.UseDefault);
|
||||
CliUsageException ex = Assert.Throws<CliUsageException>(() => options.Resolve());
|
||||
Assert.Contains("--use-default-token", ex.Message);
|
||||
Assert.Contains("accounts add", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void UseDefaultToken_CombinedWithAnotherSource_IsAUsageError()
|
||||
{
|
||||
CliUsageException ex = Assert.Throws<CliUsageException>(() => Parse("--use-default-token", "--token-stdin").Resolve());
|
||||
|
||||
Assert.Contains("only one of", ex.Message);
|
||||
Assert.Contains("--use-default-token", ex.Message);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void UseDefaultToken_RejectsAValue()
|
||||
=> Assert.Throws<CliUsageException>(() => Parse("--use-default-token=yes"));
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void TryConsume_NonTokenOption_ReturnsFalse()
|
||||
{
|
||||
|
||||
@@ -21,4 +21,5 @@ internal sealed record AccountSummary(
|
||||
bool CreateOrgSubfolder,
|
||||
int MaxConcurrency,
|
||||
DateTimeOffset? LastSync,
|
||||
string? LastSyncSummary);
|
||||
string? LastSyncSummary,
|
||||
string TokenSource);
|
||||
+62
-14
@@ -32,12 +32,14 @@ internal static class AccountsCommand
|
||||
|
||||
list (default)
|
||||
Prints the accounts, one per line, in aligned columns: name,
|
||||
organization, target root, and last sync time (local time, 'never'
|
||||
when the account has not completed a sync yet).
|
||||
organization, target root, token source ('own' or 'default'), and last
|
||||
sync time (local time, 'never' when the account has not completed a
|
||||
sync yet).
|
||||
--json Print a single-line JSON array instead:
|
||||
[{"id":"...","name":"...","description":"...","organization":"...",
|
||||
"targetRoot":"...","createOrgSubfolder":bool,"maxConcurrency":N,
|
||||
"lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null}]
|
||||
"lastSync":"2026-07-04T15:30:00+00:00"|null,"lastSyncSummary":"..."|null,
|
||||
"tokenSource":"own"|"default"}]
|
||||
|
||||
add
|
||||
--name <name> Display name; must be unique (case-insensitive).
|
||||
@@ -49,6 +51,10 @@ internal static class AccountsCommand
|
||||
--token-env <VAR> | --token-file <path> | --token-stdin
|
||||
The token to store (default: the GITHUB_TOKEN
|
||||
environment variable). Never pass it as an argument.
|
||||
--use-default-token Instead of a token of its own, the account uses
|
||||
the default token saved in the desktop app's
|
||||
Settings, read whenever it syncs, so replacing
|
||||
the default token there updates this account too.
|
||||
|
||||
edit
|
||||
--name <name> The account to change (required).
|
||||
@@ -58,8 +64,11 @@ internal static class AccountsCommand
|
||||
--org-subfolder | --no-org-subfolder
|
||||
Turn the organization subfolder on or off.
|
||||
--token-env | --token-file | --token-stdin
|
||||
Replace the stored token; without a token option
|
||||
the stored token is left untouched.
|
||||
Replace the stored token (and switch an account
|
||||
on the default token to a token of its own);
|
||||
without a token option the token is left untouched.
|
||||
--use-default-token Switch the account to the default token; its own
|
||||
token is removed from Windows Credential Manager.
|
||||
|
||||
remove
|
||||
--name <name> Deletes the account's settings and its stored
|
||||
@@ -137,7 +146,7 @@ internal static class AccountsCommand
|
||||
IReadOnlyList<AccountSummary> summaries = accounts
|
||||
.Select(a => new AccountSummary(
|
||||
a.Id.ToString("N"), a.Name, a.Description, a.Organization, a.TargetRoot,
|
||||
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary))
|
||||
a.CreateOrgSubfolder, a.MaxConcurrency, a.LastSyncUtc, a.LastSyncSummary, TokenSourceLabel(a)))
|
||||
.ToList();
|
||||
Console.Out.WriteLine(JsonSerializer.Serialize(
|
||||
summaries, CliJsonContext.Default.IReadOnlyListAccountSummary));
|
||||
@@ -158,7 +167,7 @@ internal static class AccountsCommand
|
||||
{
|
||||
Console.Out.WriteLine(
|
||||
$"{account.Name.PadRight(nameWidth)} {account.Organization.PadRight(orgWidth)} "
|
||||
+ $"{account.TargetRoot.PadRight(targetWidth)} {FormatLastSync(account.LastSyncUtc)}");
|
||||
+ $"{account.TargetRoot.PadRight(targetWidth)} {TokenSourceLabel(account).PadRight(7)} {FormatLastSync(account.LastSyncUtc)}");
|
||||
}
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
@@ -234,11 +243,14 @@ internal static class AccountsCommand
|
||||
throw new CliUsageException("--target is required.");
|
||||
}
|
||||
|
||||
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}'");
|
||||
log.Info($"accounts add started: name='{name.Trim()}', org='{org.Trim()}', token={(tokenOptions.UseDefault ? "default" : "own")}");
|
||||
try
|
||||
{
|
||||
string token = tokenOptions.Resolve();
|
||||
(AccountsStore store, _) = open(log);
|
||||
// The token (if the account gets one of its own) is read before the store
|
||||
// opens, so a bad token option fails without touching accounts.json.
|
||||
string? token = tokenOptions.UseDefault ? null : tokenOptions.Resolve();
|
||||
(AccountsStore store, ITokenVault vault) = open(log);
|
||||
RequireDefaultTokenIfUsed(tokenOptions, vault);
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
@@ -248,9 +260,10 @@ internal static class AccountsCommand
|
||||
TargetRoot = target.Trim(),
|
||||
CreateOrgSubfolder = orgSubfolder,
|
||||
MaxConcurrency = parallel,
|
||||
TokenSource = tokenOptions.UseDefault ? TokenSource.Default : TokenSource.Own,
|
||||
};
|
||||
SaveOrFail(store, account, token);
|
||||
Console.Out.WriteLine($"Account '{account.Name}' added.");
|
||||
Console.Out.WriteLine($"Account '{account.Name}' added" + (tokenOptions.UseDefault ? " (uses the default token)." : "."));
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch(Exception ex)
|
||||
@@ -332,10 +345,17 @@ internal static class AccountsCommand
|
||||
log.Info($"accounts edit started: name='{name.Trim()}'");
|
||||
try
|
||||
{
|
||||
(AccountsStore store, _) = open(log);
|
||||
(AccountsStore store, ITokenVault vault) = open(log);
|
||||
Account existing = store.FindByName(name.Trim()) ?? throw UnknownAccount(name.Trim(), store);
|
||||
string? token = tokenOptions.HasExplicitSource ? tokenOptions.Resolve() : null;
|
||||
RequireDefaultTokenIfUsed(tokenOptions, vault);
|
||||
string? token = tokenOptions.HasExplicitSource && !tokenOptions.UseDefault ? tokenOptions.Resolve() : null;
|
||||
|
||||
// A token option decides the source: --use-default-token switches to the
|
||||
// default token (and drops the own entry); --token-* gives the account a
|
||||
// token of its own; neither leaves the source and the token untouched.
|
||||
TokenSource source = tokenOptions.UseDefault
|
||||
? TokenSource.Default
|
||||
: token is null ? existing.TokenSource : TokenSource.Own;
|
||||
Account updated = existing with
|
||||
{
|
||||
Name = rename?.Trim() ?? existing.Name,
|
||||
@@ -344,9 +364,13 @@ internal static class AccountsCommand
|
||||
Description = description?.Trim() ?? existing.Description,
|
||||
MaxConcurrency = parallel ?? existing.MaxConcurrency,
|
||||
CreateOrgSubfolder = orgSubfolder ?? existing.CreateOrgSubfolder,
|
||||
TokenSource = source,
|
||||
};
|
||||
SaveOrFail(store, updated, token);
|
||||
Console.Out.WriteLine($"Account '{updated.Name}' updated" + (token is null ? "." : " (token replaced)."));
|
||||
string tokenNote = tokenOptions.UseDefault
|
||||
? " (now uses the default token)."
|
||||
: token is null ? "." : " (token replaced).";
|
||||
Console.Out.WriteLine($"Account '{updated.Name}' updated" + tokenNote);
|
||||
return ExitCodes.Success;
|
||||
}
|
||||
catch(Exception ex)
|
||||
@@ -405,6 +429,30 @@ internal static class AccountsCommand
|
||||
|
||||
// ---------------------------------------------------------------- shared
|
||||
|
||||
/// <summary>'own' or 'default': the token source as the list column and the JSON field show it.</summary>
|
||||
private static string TokenSourceLabel(Account account) => account.UsesDefaultToken ? "default" : "own";
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// --use-default-token is only useful when a default token exists: without one
|
||||
/// the account could never sync, so the command fails up front and says where
|
||||
/// the default token is set (the CLI has no way to set it).
|
||||
/// </summary>
|
||||
private static void RequireDefaultTokenIfUsed(TokenOptions tokenOptions, ITokenVault vault)
|
||||
{
|
||||
tokenOptions.EnsureAtMostOneSource();
|
||||
if(tokenOptions.UseDefault && !new AccountTokenResolver(vault).HasDefaultToken)
|
||||
{
|
||||
throw new CliErrorException(
|
||||
"No default token is saved, so the account could not sync. Save one in the desktop app "
|
||||
+ "(Settings > Default GitHub token), or give the account a token of its own with --token-env, "
|
||||
+ "--token-file, or --token-stdin.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>A duplicate name is the one validation the store itself enforces; everything else is fatal as is.</summary>
|
||||
private static void SaveOrFail(AccountsStore store, Account account, string? token)
|
||||
{
|
||||
|
||||
@@ -468,14 +468,17 @@ internal static class SyncCommand
|
||||
}
|
||||
|
||||
// vault is non-null whenever account is: both come from AccountsCommand.Open.
|
||||
string? token = vault!.TryRetrieve(account.Id);
|
||||
if(string.IsNullOrEmpty(token))
|
||||
string? token = new AccountTokenResolver(vault!).TryResolve(account);
|
||||
if(token is null)
|
||||
{
|
||||
string fix = account.UsesDefaultToken
|
||||
? "Save a default token in the desktop app's Settings, switch the account to its own token with "
|
||||
+ "'gclo accounts edit --name <name> --token-stdin',"
|
||||
: "Re-enter the token with 'gclo accounts edit --name <name> --token-stdin' or in the desktop app's "
|
||||
+ "account wizard, restore the credential entry manually,";
|
||||
throw new CliErrorException(
|
||||
$"Account '{account.Name}' has no token in Windows Credential Manager "
|
||||
+ $"(entry 'gclo:account:{account.Id:N}'). Re-enter the token with "
|
||||
+ "'gclo accounts edit --name <name> --token-stdin' or in the desktop app's account wizard, "
|
||||
+ "restore the credential entry manually, or pass a token with --token-env, --token-file, or --token-stdin.");
|
||||
$"Account '{account.Name}' {AccountTokenResolver.MissingDescription(account)}. {fix} "
|
||||
+ "or pass a token with --token-env, --token-file, or --token-stdin.");
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -25,14 +25,25 @@ internal sealed class TokenOptions
|
||||
|
||||
private bool _useStdin;
|
||||
|
||||
private bool _useDefault;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// True when any token option (--token-env, --token-file, --token-stdin) was
|
||||
/// given. 'gclo sync --account' uses this to let an explicit token source
|
||||
/// override the account's vault-stored token.
|
||||
/// True when any token option (--token-env, --token-file, --token-stdin,
|
||||
/// --use-default-token) was given. 'gclo sync --account' uses this to let an
|
||||
/// explicit token source override the account's stored token.
|
||||
/// </summary>
|
||||
public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin;
|
||||
public bool HasExplicitSource => _envVariable is not null || _filePath is not null || _useStdin || _useDefault;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// --use-default-token: the account resolves to the default token saved in the
|
||||
/// desktop app's Settings instead of holding a token of its own (accounts add
|
||||
/// and edit only). <see cref="Resolve"/> rejects it, as it names no token value.
|
||||
/// </summary>
|
||||
public bool UseDefault => _useDefault;
|
||||
|
||||
|
||||
|
||||
@@ -51,6 +62,10 @@ internal sealed class TokenOptions
|
||||
reader.RejectValue();
|
||||
_useStdin = true;
|
||||
return true;
|
||||
case "--use-default-token":
|
||||
reader.RejectValue();
|
||||
_useDefault = true;
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -58,6 +73,18 @@ internal sealed class TokenOptions
|
||||
|
||||
|
||||
|
||||
/// <summary>Usage error when more than one token option was given; a no-op otherwise.</summary>
|
||||
public void EnsureAtMostOneSource()
|
||||
{
|
||||
int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0) + (_useDefault ? 1 : 0);
|
||||
if(sources > 1)
|
||||
{
|
||||
throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin, --use-default-token.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the token from the single configured source, or from the
|
||||
/// <see cref="DefaultVariable"/> environment variable when none was given.
|
||||
@@ -65,10 +92,11 @@ internal sealed class TokenOptions
|
||||
/// </summary>
|
||||
public string Resolve()
|
||||
{
|
||||
int sources = (_envVariable is null ? 0 : 1) + (_filePath is null ? 0 : 1) + (_useStdin ? 1 : 0);
|
||||
if(sources > 1)
|
||||
EnsureAtMostOneSource();
|
||||
if(_useDefault)
|
||||
{
|
||||
throw new CliUsageException("Use only one of --token-env, --token-file, --token-stdin.");
|
||||
throw new CliUsageException(
|
||||
"--use-default-token names no token value; it is for 'gclo accounts add' and 'gclo accounts edit' only.");
|
||||
}
|
||||
|
||||
if(_useStdin)
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using gclo.ViewModels;
|
||||
|
||||
|
||||
namespace gclo.Engine.Tests;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="AccountTokenResolver"/>: an account's own entry for <see cref="TokenSource.Own"/>,
|
||||
/// the default token for <see cref="TokenSource.Default"/>, null (with a source-specific
|
||||
/// description) when the source holds nothing.
|
||||
/// </summary>
|
||||
public sealed class AccountTokenResolverTests
|
||||
{
|
||||
private readonly InMemoryVault _vault = new();
|
||||
|
||||
|
||||
|
||||
private static Account MakeAccount(TokenSource source) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "work",
|
||||
Organization = "acme",
|
||||
TargetRoot = @"C:\repos",
|
||||
TokenSource = source,
|
||||
};
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void NullVault_Throws()
|
||||
=> Assert.Throws<ArgumentNullException>(() => new AccountTokenResolver(null!));
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void NullAccount_Throws()
|
||||
{
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
|
||||
Assert.Throws<ArgumentNullException>(() => resolver.TryResolve(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => AccountTokenResolver.MissingDescription(null!));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void OwnAccount_ResolvesItsOwnEntry_NotTheDefault()
|
||||
{
|
||||
Account account = MakeAccount(TokenSource.Own);
|
||||
_vault.Store(account.Id, "ghp_own");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
|
||||
Assert.Equal("ghp_own", new AccountTokenResolver(_vault).TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DefaultAccount_ResolvesTheDefaultToken_EvenWhenAnOwnEntryLingers()
|
||||
{
|
||||
Account account = MakeAccount(TokenSource.Default);
|
||||
_vault.Store(account.Id, "ghp_stale");
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
|
||||
Assert.Equal("ghp_default", new AccountTokenResolver(_vault).TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void DefaultAccount_FollowsRotationOfTheDefaultToken()
|
||||
{
|
||||
Account account = MakeAccount(TokenSource.Default);
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_first");
|
||||
Assert.Equal("ghp_first", resolver.TryResolve(account));
|
||||
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_rotated");
|
||||
|
||||
Assert.Equal("ghp_rotated", resolver.TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(TokenSource.Own)]
|
||||
[InlineData(TokenSource.Default)]
|
||||
public void MissingSource_ResolvesNull(TokenSource source)
|
||||
{
|
||||
Account account = MakeAccount(source);
|
||||
|
||||
Assert.Null(new AccountTokenResolver(_vault).TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void EmptyStoredValue_CountsAsMissing()
|
||||
{
|
||||
Account account = MakeAccount(TokenSource.Own);
|
||||
_vault.Store(account.Id, "");
|
||||
|
||||
Assert.Null(new AccountTokenResolver(_vault).TryResolve(account));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void HasDefaultToken_ReflectsTheVault()
|
||||
{
|
||||
var resolver = new AccountTokenResolver(_vault);
|
||||
Assert.False(resolver.HasDefaultToken);
|
||||
|
||||
_vault.Store(AppSettings.DefaultTokenVaultId, "ghp_default");
|
||||
|
||||
Assert.True(resolver.HasDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void MissingDescription_NamesTheSource_AndNeverATokenValue()
|
||||
{
|
||||
Account own = MakeAccount(TokenSource.Own);
|
||||
Account byDefault = MakeAccount(TokenSource.Default);
|
||||
|
||||
string ownText = AccountTokenResolver.MissingDescription(own);
|
||||
string defaultText = AccountTokenResolver.MissingDescription(byDefault);
|
||||
|
||||
Assert.Contains($"gclo:account:{own.Id:N}", ownText);
|
||||
Assert.Contains("Settings", defaultText);
|
||||
Assert.DoesNotContain("ghp", ownText + defaultText);
|
||||
}
|
||||
}
|
||||
@@ -388,6 +388,67 @@ public sealed class AccountsStoreConsistencyTests : IDisposable
|
||||
/// Delete can be made to throw (from a given call number on) without changing
|
||||
/// state — the contract a real secret store keeps on failure.
|
||||
/// </summary>
|
||||
// ---------------------------------------------------------------- save: switching to the default token (#102)
|
||||
|
||||
[Fact]
|
||||
public void Save_SwitchToDefaultToken_MetadataWriteFails_RestoresTheOwnToken()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account account = MakeAccount("Work");
|
||||
store.Save(account, OldToken);
|
||||
BlockMetadataWrites();
|
||||
_vault.ResetCounters();
|
||||
|
||||
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account with { TokenSource = TokenSource.Default }, null));
|
||||
|
||||
Assert.Equal(OldToken, _vault.TryRetrieve(account.Id));
|
||||
Assert.Equal(1, _vault.DeleteCalls);
|
||||
Assert.Equal(1, _vault.StoreCalls);
|
||||
Assert.False(Assert.Single(store.GetAll()).UsesDefaultToken);
|
||||
Assert.False(Assert.Single(OnDisk()).UsesDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_SwitchToDefaultToken_MetadataWriteFails_WithNoOwnToken_JustPropagates()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account account = MakeAccount("Work");
|
||||
store.Save(account, OldToken);
|
||||
_vault.Delete(account.Id); // nothing of its own to put back
|
||||
BlockMetadataWrites();
|
||||
_vault.ResetCounters();
|
||||
|
||||
Assert.Throws<UnauthorizedAccessException>(() => store.Save(account with { TokenSource = TokenSource.Default }, null));
|
||||
|
||||
Assert.Equal(0, _vault.DeleteCalls);
|
||||
Assert.Equal(0, _vault.StoreCalls);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_SwitchToDefaultToken_MetadataWriteFails_AndRestoreFails_ReportsBoth()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account account = MakeAccount("Work");
|
||||
store.Save(account, OldToken);
|
||||
BlockMetadataWrites();
|
||||
_vault.StoreFailure = new InvalidOperationException("vault offline");
|
||||
_vault.ResetCounters();
|
||||
|
||||
AccountConsistencyException ex = Assert.Throws<AccountConsistencyException>(
|
||||
() => store.Save(account with { TokenSource = TokenSource.Default }, null));
|
||||
|
||||
Assert.Contains("vault offline", ex.Message);
|
||||
Assert.Contains("lost its own token", ex.Message);
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
Assert.Contains(_log.Errors, e => e.Message.Contains("could not be restored", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
||||
|
||||
private sealed class FaultableVault : ITokenVault
|
||||
{
|
||||
private readonly InMemoryVault _inner = new();
|
||||
|
||||
@@ -42,6 +42,111 @@ public sealed class AccountsStoreTests : IDisposable
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------- default token (#102)
|
||||
|
||||
[Fact]
|
||||
public void Save_DefaultTokenAccount_WritesNoOwnEntry_AndRoundTripsTheSource()
|
||||
{
|
||||
Account account = MakeAccount("Shared") with { TokenSource = TokenSource.Default };
|
||||
|
||||
NewStore().Save(account, token: null);
|
||||
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
Account reloaded = Assert.Single(NewStore().GetAll());
|
||||
Assert.Equal(TokenSource.Default, reloaded.TokenSource);
|
||||
Assert.True(reloaded.UsesDefaultToken);
|
||||
Assert.Contains("\"TokenSource\": \"Default\"", File.ReadAllText(Path.Combine(_root, "accounts.json")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_OwnTokenAccount_OmitsTheSourceFromTheFile_SoOldFilesStayIdentical()
|
||||
{
|
||||
NewStore().Save(MakeAccount("Work"), "ghp_own");
|
||||
|
||||
string json = File.ReadAllText(Path.Combine(_root, "accounts.json"));
|
||||
Assert.DoesNotContain("TokenSource", json);
|
||||
Assert.Equal(TokenSource.Own, Assert.Single(NewStore().GetAll()).TokenSource);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Load_FileWithoutTheSourceField_ReadsAsOwn()
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
var id = Guid.NewGuid();
|
||||
File.WriteAllText(
|
||||
Path.Combine(_root, "accounts.json"),
|
||||
$$"""[{"Id":"{{id}}","Name":"Old","Organization":"acme","TargetRoot":"C:\\repos"}]""");
|
||||
|
||||
Account loaded = Assert.Single(NewStore().GetAll());
|
||||
|
||||
Assert.Equal(TokenSource.Own, loaded.TokenSource);
|
||||
Assert.False(loaded.UsesDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_DefaultTokenAccount_WithAToken_IsRejected()
|
||||
{
|
||||
Account account = MakeAccount("Shared") with { TokenSource = TokenSource.Default };
|
||||
|
||||
ArgumentException ex = Assert.Throws<ArgumentException>(() => NewStore().Save(account, "ghp_stray"));
|
||||
|
||||
Assert.Contains("default token", ex.Message);
|
||||
Assert.Empty(NewStore().GetAll());
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_SwitchingToTheDefaultToken_RemovesTheOwnEntry()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account account = MakeAccount("Work");
|
||||
store.Save(account, "ghp_own");
|
||||
Assert.Equal("ghp_own", _vault.TryRetrieve(account.Id));
|
||||
|
||||
store.Save(account with { TokenSource = TokenSource.Default }, token: null);
|
||||
|
||||
Assert.Null(_vault.TryRetrieve(account.Id));
|
||||
Assert.True(Assert.Single(store.GetAll()).UsesDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void Save_SwitchingBackToAnOwnToken_StoresIt()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Account account = MakeAccount("Work") with { TokenSource = TokenSource.Default };
|
||||
store.Save(account, token: null);
|
||||
|
||||
store.Save(account with { TokenSource = TokenSource.Own }, "ghp_mine");
|
||||
|
||||
Assert.Equal("ghp_mine", _vault.TryRetrieve(account.Id));
|
||||
Assert.False(Assert.Single(store.GetAll()).UsesDefaultToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void CountUsingDefaultToken_CountsOnlyDefaultTokenAccounts()
|
||||
{
|
||||
AccountsStore store = NewStore();
|
||||
Assert.Equal(0, store.CountUsingDefaultToken());
|
||||
store.Save(MakeAccount("A") with { TokenSource = TokenSource.Default }, null);
|
||||
store.Save(MakeAccount("B") with { TokenSource = TokenSource.Default }, null);
|
||||
store.Save(MakeAccount("C"), "ghp_c");
|
||||
|
||||
Assert.Equal(2, store.CountUsingDefaultToken());
|
||||
Assert.Equal(2, NewStore().CountUsingDefaultToken());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- persistence
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1793,6 +1793,54 @@ public sealed class WorkspaceViewModelTests : IDisposable
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AccountConstruction_DefaultTokenAccount_UsesTheDefaultToken()
|
||||
{
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Shared",
|
||||
Organization = "acme",
|
||||
TargetRoot = @"C:\repos\shared",
|
||||
TokenSource = TokenSource.Default,
|
||||
};
|
||||
var vault = new InMemoryVault();
|
||||
vault.Store(AppSettings.DefaultTokenVaultId, "default-token-1234567890");
|
||||
string? lookedUpToken = null;
|
||||
_orgs.Handler = (token, _) =>
|
||||
{
|
||||
lookedUpToken = token;
|
||||
return Task.FromResult<IReadOnlyList<string>>(["acme"]);
|
||||
};
|
||||
|
||||
WorkspaceViewModel vm = CreateViewModel(account: account, vault: vault);
|
||||
|
||||
Assert.Equal("default-token-1234567890", vm.Token);
|
||||
await WaitUntilAsync(() => lookedUpToken is not null, "org lookup to fire");
|
||||
Assert.Equal("default-token-1234567890", lookedUpToken);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public void AccountConstruction_DefaultTokenAccount_WithoutADefaultToken_StartsEmpty()
|
||||
{
|
||||
var account = new Account
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Shared",
|
||||
Organization = "acme",
|
||||
TargetRoot = @"C:\repos\shared",
|
||||
TokenSource = TokenSource.Default,
|
||||
};
|
||||
|
||||
WorkspaceViewModel vm = CreateViewModel(account: account, vault: new InMemoryVault());
|
||||
|
||||
Assert.Equal("", vm.Token);
|
||||
}
|
||||
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task AccountConstruction_SeedsWorkspace_AndLooksUpOrgsWithVaultToken()
|
||||
{
|
||||
|
||||
@@ -3,20 +3,37 @@
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// A saved connection profile: which GitHub organization to sync, where its clones
|
||||
/// live on disk, and how the sync runs. Metadata only — the access token is held
|
||||
/// separately in an <see cref="ITokenVault"/> keyed by <see cref="Id"/> and is never
|
||||
/// serialized alongside the account.
|
||||
/// separately in an <see cref="ITokenVault"/> (keyed by <see cref="Id"/>, or the
|
||||
/// default token when <see cref="TokenSource"/> says so) and is never serialized
|
||||
/// alongside the account.
|
||||
/// </summary>
|
||||
public sealed record Account
|
||||
{
|
||||
/// <summary>Stable identity: keys the vault entry and survives renames.</summary>
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the token comes from: this account's own vault entry (the default, and
|
||||
/// omitted from accounts.json so files written before this field exist stay
|
||||
/// byte-identical) or the default token from Settings, resolved at use time by
|
||||
/// <see cref="AccountTokenResolver"/>.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<TokenSource>))]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
|
||||
public TokenSource TokenSource { get; init; } = TokenSource.Own;
|
||||
|
||||
/// <summary>True when the account resolves to the default token from Settings.</summary>
|
||||
[JsonIgnore]
|
||||
public bool UsesDefaultToken => TokenSource == TokenSource.Default;
|
||||
|
||||
/// <summary>Display name; unique across accounts (case-insensitive).</summary>
|
||||
public required string Name { get; init; }
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The one place that turns an <see cref="Account"/> into the token to use for it:
|
||||
/// its own vault entry for <see cref="TokenSource.Own"/>, the default token from
|
||||
/// Settings for <see cref="TokenSource.Default"/>. The desktop workspace, Sync All,
|
||||
/// the account wizard and the CLI all resolve through here, so no consumer reads
|
||||
/// the vault for an account directly (docs/plans/default-token-ux.md).
|
||||
/// </summary>
|
||||
public sealed class AccountTokenResolver
|
||||
{
|
||||
private readonly ITokenVault _vault;
|
||||
|
||||
|
||||
|
||||
/// <summary>Resolves against <paramref name="vault"/>.</summary>
|
||||
public AccountTokenResolver(ITokenVault vault)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(vault);
|
||||
_vault = vault;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The token for <paramref name="account"/>, or null when its source holds
|
||||
/// nothing (no default token saved, or the account's own entry is gone).
|
||||
/// </summary>
|
||||
public string? TryResolve(Account account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
string? token = account.TokenSource == TokenSource.Default
|
||||
? _vault.TryRetrieve(AppSettings.DefaultTokenVaultId)
|
||||
: _vault.TryRetrieve(account.Id);
|
||||
return string.IsNullOrEmpty(token) ? null : token;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>True when the vault holds a default token.</summary>
|
||||
public bool HasDefaultToken => !string.IsNullOrEmpty(_vault.TryRetrieve(AppSettings.DefaultTokenVaultId));
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Why <see cref="TryResolve"/> returned null for <paramref name="account"/>, worded
|
||||
/// for the user and naming the source, so every "no token" message says what to fix.
|
||||
/// Never contains a token.
|
||||
/// </summary>
|
||||
public static string MissingDescription(Account account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account);
|
||||
return account.TokenSource == TokenSource.Default
|
||||
? "uses the default token, but no default token is saved (desktop app: Settings)"
|
||||
: $"has no token in Windows Credential Manager (entry 'gclo:account:{account.Id:N}')";
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,17 @@ public sealed class AccountsStore
|
||||
}
|
||||
|
||||
string verb = index >= 0 ? "updated" : "added";
|
||||
if(account.UsesDefaultToken)
|
||||
{
|
||||
if(token is not null)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"An account that uses the default token has no token of its own to store.", nameof(token));
|
||||
}
|
||||
SaveUsingDefaultToken(updated, account, verb);
|
||||
return;
|
||||
}
|
||||
|
||||
if(token is null)
|
||||
{
|
||||
Persist(updated); // IO failures propagate; _accounts stays unchanged then.
|
||||
@@ -191,6 +202,62 @@ public sealed class AccountsStore
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Saves an account that resolves to the default token: its metadata is written
|
||||
/// and any token of its own is removed from the vault, vault first, with the same
|
||||
/// put-it-back compensation as a delete. Called under the gate.
|
||||
/// </summary>
|
||||
private void SaveUsingDefaultToken(List<Account> updated, Account account, string verb)
|
||||
{
|
||||
string? ownToken = _vault.TryRetrieve(account.Id);
|
||||
if(ownToken is not null)
|
||||
{
|
||||
_vault.Delete(account.Id);
|
||||
}
|
||||
try
|
||||
{
|
||||
Persist(updated);
|
||||
}
|
||||
catch(Exception persistFailure)
|
||||
{
|
||||
if(ownToken is null)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
try
|
||||
{
|
||||
_vault.Store(account.Id, ownToken);
|
||||
}
|
||||
catch(Exception compensationFailure)
|
||||
{
|
||||
string leftover = $"account {account.Id:N} ('{account.Name}') lost its own token from the vault while accounts.json "
|
||||
+ "still says it has one; edit it and enter the token again, or switch it to the default token";
|
||||
_log.Error($"Account save failed and the vault could not be restored: {leftover}.", compensationFailure);
|
||||
throw new AccountConsistencyException(
|
||||
$"Saving account '{account.Name}' failed ({persistFailure.Message}), and restoring its token "
|
||||
+ $"failed too ({compensationFailure.Message}). Current state: {leftover}.",
|
||||
persistFailure, compensationFailure);
|
||||
}
|
||||
throw;
|
||||
}
|
||||
_accounts = updated;
|
||||
_log.Info($"Account '{account.Name}' {verb}; it uses the default token"
|
||||
+ (ownToken is null ? "." : " (its own token was removed from the vault)."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>How many accounts resolve to the default token (Settings shows what a removal breaks).</summary>
|
||||
public int CountUsingDefaultToken()
|
||||
{
|
||||
lock(_gate)
|
||||
{
|
||||
return _accounts.Count(a => a.UsesDefaultToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Removes the account's vault token and then its metadata; an unknown id is a
|
||||
/// no-op. A vault failure changes nothing. If the metadata write fails after the
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2026 James Maes (KofTwentyTwo)
|
||||
* SPDX-License-Identifier: MIT
|
||||
*/
|
||||
|
||||
namespace gclo.ViewModels;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Where an account's GitHub token comes from. <see cref="Own"/> is the account's
|
||||
/// own vault entry (keyed by its id); <see cref="Default"/> is the default token
|
||||
/// saved in Settings (<see cref="AppSettings.DefaultTokenVaultId"/>), read at use
|
||||
/// time, so replacing the default token in Settings rotates every such account
|
||||
/// at once (docs/plans/default-token-ux.md).
|
||||
/// </summary>
|
||||
public enum TokenSource
|
||||
{
|
||||
/// <summary>The account's own token, stored under its id. The on-disk default.</summary>
|
||||
Own = 0,
|
||||
|
||||
/// <summary>The default token from Settings, resolved whenever the account is used.</summary>
|
||||
Default = 1,
|
||||
}
|
||||
@@ -140,8 +140,9 @@ public sealed partial class WorkspaceViewModel : ObservableObject, IDisposable
|
||||
TargetFolder = account.TargetRoot;
|
||||
CreateOrgSubfolder = account.CreateOrgSubfolder;
|
||||
MaxConcurrency = account.MaxConcurrency;
|
||||
// The Token setter's existing org lookup fires naturally with the vault token.
|
||||
Token = tokenVault?.TryRetrieve(account.Id) ?? "";
|
||||
// The Token setter's existing org lookup fires naturally with the resolved
|
||||
// token: the account's own entry, or the default token from Settings.
|
||||
Token = tokenVault is null ? "" : new AccountTokenResolver(tokenVault).TryResolve(account) ?? "";
|
||||
}
|
||||
_constructed = true;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user