mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-03 16:56:56 +00:00
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>
220 lines
6.8 KiB
Go
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)
|
|
}
|