Files
Evan JarrettandClaude Opus 4.8 8a556a5893 credhelper: key accounts by DID (v3 config) with lossless migration
The credential helper stored accounts keyed by handle, which broke on
handle renames and let the Docker `store` path (username+secret, no DID)
overwrite a good account with a DID-less one — how evan.jarrett.net on
buoy.cr ended up active with a blank DID.

Re-key everything by the stable DID, treating handle as a mutable display
label. DID is recovered client-side via standard AT-proto handle
resolution (DNS TXT _atproto.<handle> + HTTPS .well-known/atproto-did) —
no server change, no JWT, no auth, and no indigo pulled into the helper.

- resolve.go: stdlib handle->DID resolver
- config.go: v3 DID-keyed schema; find/activeAccount/upsert/rekey helpers;
  upsert never blanks a known DID; migrateV2toV3 re-keys existing files in
  place (no login lost; DID-less accounts stay provisional and self-heal)
- protocol.go: store resolves+preserves DID; get lazily backfills+re-keys;
  list reports the active account's handle (was arbitrary map iteration)
- status/switch/logout: display handle, key/compare by DID
- config_test.go: migration, upsert-never-blanks-DID, rekey, find, list,
  resolver parse helpers

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 13:38:45 -05:00

220 lines
6.8 KiB
Go

package credhelper
import (
"encoding/json"
"io"
"os"
"path/filepath"
"testing"
)
// setupConfigDir points the config helpers at a temp dir and installs a minimal
// cfg for the duration of the test.
func setupConfigDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
oldCfg, oldOverride := cfg, configDirOverride
cfg = Config{BinaryName: "docker-credential-test", DefaultRegistry: "atcr.io"}
configDirOverride = dir
t.Cleanup(func() { cfg, configDirOverride = oldCfg, oldOverride })
return dir
}
// writeConfigFile writes raw JSON to the active config path.
func writeConfigFile(t *testing.T, dir, contents string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "device.json"), []byte(contents), 0600); err != nil {
t.Fatal(err)
}
}
func TestMigrateV2toV3_PreservesAndReKeys(t *testing.T) {
dir := setupConfigDir(t)
// v2 file: one account WITH a DID (should re-key to DID), one WITHOUT
// (stays under its handle as a provisional key). Active is a handle.
writeConfigFile(t, dir, `{
"version": 2,
"registries": {
"https://atcr.io": {
"active": "evan.jarrett.net",
"accounts": {
"evan.jarrett.net": {"handle": "evan.jarrett.net", "device_secret": "atcr_device_A"},
"tnybot.jarrett.app": {"handle": "tnybot.jarrett.app", "did": "did:plc:tny", "device_secret": "atcr_device_B"}
}
}
}
}`)
sc, err := loadConfig()
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if sc.Version != configVersion {
t.Fatalf("version = %d, want %d", sc.Version, configVersion)
}
reg := sc.Registries["https://atcr.io"]
if reg == nil {
t.Fatal("registry missing after migration")
}
if len(reg.Accounts) != 2 {
t.Fatalf("accounts = %d, want 2 (none dropped)", len(reg.Accounts))
}
// Account with a DID is keyed by DID.
if a := reg.Accounts["did:plc:tny"]; a == nil || a.DeviceSecret != "atcr_device_B" {
t.Errorf("did-keyed account not found or wrong secret: %+v", a)
}
// DID-less account survives under its handle key.
if a := reg.Accounts["evan.jarrett.net"]; a == nil || a.DeviceSecret != "atcr_device_A" {
t.Errorf("provisional handle-keyed account not preserved: %+v", a)
}
// Active carried over and still resolves to the same handle.
if act := reg.activeAccount(); act == nil || act.Handle != "evan.jarrett.net" {
t.Errorf("active = %+v, want handle evan.jarrett.net", act)
}
// Migration persisted to disk as v3.
data, _ := os.ReadFile(filepath.Join(dir, "device.json"))
var onDisk StoredConfig
if err := json.Unmarshal(data, &onDisk); err != nil {
t.Fatal(err)
}
if onDisk.Version != configVersion {
t.Errorf("on-disk version = %d, want %d", onDisk.Version, configVersion)
}
}
func TestUpsert_MergesAndNeverBlanksDID(t *testing.T) {
reg := &RegistryConfig{Accounts: map[string]*Account{}}
// First a login-created account with a real DID.
reg.upsert(&Account{Handle: "evan.jarrett.net", DID: "did:plc:evan", DeviceSecret: "secret1"})
reg.setActive(reg.Accounts["did:plc:evan"])
// Then a Docker `store` for the same handle with NO DID and a new secret —
// this is the regression: it must NOT blank the existing DID.
reg.upsert(&Account{Handle: "evan.jarrett.net", DeviceSecret: "secret2"})
if len(reg.Accounts) != 1 {
t.Fatalf("accounts = %d, want 1 (merged, not duplicated)", len(reg.Accounts))
}
a := reg.Accounts["did:plc:evan"]
if a == nil {
t.Fatal("account no longer keyed by DID after store")
}
if a.DID != "did:plc:evan" {
t.Errorf("DID blanked/changed: %q", a.DID)
}
if a.DeviceSecret != "secret2" {
t.Errorf("secret not updated: %q", a.DeviceSecret)
}
if reg.Active != "did:plc:evan" {
t.Errorf("Active = %q, want did:plc:evan", reg.Active)
}
}
func TestRekey_BackfillsProvisionalAccount(t *testing.T) {
reg := &RegistryConfig{Accounts: map[string]*Account{}}
// Provisional handle-keyed account (e.g. from store when offline).
reg.upsert(&Account{Handle: "evan.jarrett.net", DeviceSecret: "s"})
reg.setActive(reg.Accounts["evan.jarrett.net"])
acct := reg.Accounts["evan.jarrett.net"]
reg.rekey(acct, "did:plc:evan")
if _, stillHandle := reg.Accounts["evan.jarrett.net"]; stillHandle {
t.Error("old handle key not removed after rekey")
}
if a := reg.Accounts["did:plc:evan"]; a == nil || a.Handle != "evan.jarrett.net" {
t.Errorf("account not re-keyed to DID: %+v", a)
}
if reg.Active != "did:plc:evan" {
t.Errorf("Active not repointed: %q", reg.Active)
}
}
func TestFind_ByDIDAndHandle(t *testing.T) {
reg := &RegistryConfig{Accounts: map[string]*Account{}}
reg.upsert(&Account{Handle: "evan.jarrett.net", DID: "did:plc:evan", DeviceSecret: "s"})
if a := reg.find("did:plc:evan"); a == nil {
t.Error("find by DID failed")
}
// Image refs use the handle even though the map is DID-keyed.
if a := reg.find("evan.jarrett.net"); a == nil {
t.Error("find by handle failed")
}
if a := reg.find("nobody.example"); a != nil {
t.Errorf("find matched nonexistent identity: %+v", a)
}
}
func TestRunList_ReportsActiveHandle(t *testing.T) {
dir := setupConfigDir(t)
// Multi-account registry: list must report the ACTIVE account's handle, not
// an arbitrary one. Written as v3 (DID-keyed).
writeConfigFile(t, dir, `{
"version": 3,
"registries": {
"https://buoy.cr": {
"active": "did:plc:evan",
"accounts": {
"did:plc:evan": {"handle": "evan.jarrett.net", "did": "did:plc:evan", "device_secret": "atcr_device_A"},
"did:plc:tny": {"handle": "tnybot.jarrett.app", "did": "did:plc:tny", "device_secret": "atcr_device_B"}
}
}
}
}`)
out := captureStdout(t, func() {
if err := runList(nil, nil); err != nil {
t.Fatalf("runList: %v", err)
}
})
var got map[string]string
if err := json.Unmarshal([]byte(out), &got); err != nil {
t.Fatalf("decoding list output %q: %v", out, err)
}
if got["buoy.cr"] != "evan.jarrett.net" {
t.Errorf("list reported %q for buoy.cr, want the active account evan.jarrett.net", got["buoy.cr"])
}
}
func TestResolveHelpers_Parse(t *testing.T) {
if !looksLikeDID("did:plc:abc") {
t.Error("did:plc:abc should look like a DID")
}
if looksLikeDID("evan.jarrett.net") {
t.Error("handle should not look like a DID")
}
if got := normalizeHandle("@Evan.Jarrett.NET/"); got != "evan.jarrett.net" {
t.Errorf("normalizeHandle = %q", got)
}
txt := []string{"some other record", "did=did:plc:xyz"}
if got := parseDIDFromTXT(txt); got != "did:plc:xyz" {
t.Errorf("parseDIDFromTXT = %q, want did:plc:xyz", got)
}
if got := parseDIDFromTXT([]string{"did=not-a-did", "nope"}); got != "" {
t.Errorf("parseDIDFromTXT accepted invalid DID: %q", got)
}
}
// captureStdout redirects os.Stdout for the duration of fn and returns what was
// written.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
fn()
w.Close()
os.Stdout = old
data, _ := io.ReadAll(r)
return string(data)
}