diff --git a/internal/auth/credentials.go b/internal/auth/credentials.go index b632082..e4b8949 100644 --- a/internal/auth/credentials.go +++ b/internal/auth/credentials.go @@ -6,9 +6,16 @@ import ( "time" ) -// Credentials matches the schema used by the Gradle plugin (Phase 2.2 §5). -// CLI is the canonical writer; the Gradle plugin reads it. +// CredentialsVersion is the schema version we write. Must match what +// `grounds-push`'s CredentialResolver accepts (currently 1). Bump in +// lockstep when the schema changes. +const CredentialsVersion = 1 + +// Credentials matches the schema used by the Gradle plugin +// (groundsgg/grounds-push CredentialResolver.kt). CLI is the canonical +// writer; the Gradle plugin reads it. type Credentials struct { + Version int `json:"version"` AccessToken string `json:"accessToken"` RefreshToken string `json:"refreshToken"` ExpiresAt time.Time `json:"expiresAt"` @@ -17,12 +24,24 @@ type Credentials struct { PreferredUser string `json:"preferredUsername,omitempty"` } -func (c *Credentials) Marshal() ([]byte, error) { return json.MarshalIndent(c, "", " ") } +// Marshal enforces the current schema version on every write so callers +// don't have to remember to set it. Files written before this field +// existed are silently upgraded on the next save (see ParseCredentials). +func (c *Credentials) Marshal() ([]byte, error) { + c.Version = CredentialsVersion + return json.MarshalIndent(c, "", " ") +} func ParseCredentials(b []byte) (*Credentials, error) { c := &Credentials{} if err := json.Unmarshal(b, c); err != nil { return nil, fmt.Errorf("parse credentials: %w", err) } + // Legacy files written before the version field existed parse with + // Version == 0. Treat as v1 — they'll be re-written with the correct + // version on the next refresh / login. + if c.Version == 0 { + c.Version = CredentialsVersion + } return c, nil } diff --git a/internal/auth/credentials_test.go b/internal/auth/credentials_test.go index db24915..7fb5b12 100644 --- a/internal/auth/credentials_test.go +++ b/internal/auth/credentials_test.go @@ -1,9 +1,11 @@ package auth import ( + "encoding/json" "os" "path/filepath" "runtime" + "strings" "testing" "time" ) @@ -47,3 +49,37 @@ func TestLoadMissing(t *testing.T) { t.Errorf("expected error on missing file") } } + +// TestMarshalAlwaysWritesVersion verifies the cross-repo schema contract +// with grounds-push's CredentialResolver (which requires version: 1). +func TestMarshalAlwaysWritesVersion(t *testing.T) { + c := &Credentials{AccessToken: "at"} + blob, err := c.Marshal() + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !strings.Contains(string(blob), `"version": 1`) { + t.Errorf("missing version field in output: %s", blob) + } + + var roundtrip Credentials + if err := json.Unmarshal(blob, &roundtrip); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if roundtrip.Version != CredentialsVersion { + t.Errorf("version = %d, want %d", roundtrip.Version, CredentialsVersion) + } +} + +// TestParseLegacyFileWithoutVersion verifies that pre-version files +// written by an older CLI parse cleanly and get upgraded on next save. +func TestParseLegacyFileWithoutVersion(t *testing.T) { + legacy := []byte(`{"accessToken":"at","refreshToken":"rt"}`) + c, err := ParseCredentials(legacy) + if err != nil { + t.Fatalf("ParseCredentials: %v", err) + } + if c.Version != CredentialsVersion { + t.Errorf("legacy file should be upgraded to v%d, got v%d", CredentialsVersion, c.Version) + } +}