// Copyright 2026 The Gitea Authors. All rights reserved. // SPDX-License-Identifier: MIT package credstore import ( "errors" "os" "path/filepath" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/zalando/go-keyring" ) // mockStore is a simple mock implementing Store[T] for testing. type mockStore[T any] struct { data map[string]T name string } func newMockStore[T any](name string) *mockStore[T] { return &mockStore[T]{ data: make(map[string]T), name: name, } } func (m *mockStore[T]) Load(clientID string) (T, error) { data, ok := m.data[clientID] if !ok { var zero T return zero, ErrNotFound } return data, nil } func (m *mockStore[T]) Save(clientID string, data T) error { m.data[clientID] = data return nil } func (m *mockStore[T]) Delete(clientID string) error { delete(m.data, clientID) return nil } func (m *mockStore[T]) String() string { return m.name } // mockProberStore implements both Store[T] and Prober. type mockProberStore[T any] struct { mockStore[T] probeResult bool } func newMockProberStore[T any](name string, probeResult bool) *mockProberStore[T] { return &mockProberStore[T]{ mockStore: mockStore[T]{data: make(map[string]T), name: name}, probeResult: probeResult, } } func (m *mockProberStore[T]) Probe() bool { return m.probeResult } func TestSecureStoreUsesKeyringWhenProbeSucceeds(t *testing.T) { kr := newMockProberStore[Token]("keyring: test", true) file := newMockStore[Token]("file: test") store := NewSecureStore[Token](kr, file) tok := Token{ AccessToken: "test-token", ClientID: "test-client", ExpiresAt: time.Now().Add(1 * time.Hour), } require.NoError(t, store.Save(tok.ClientID, tok)) // Should be in keyring, not file assert.Contains(t, kr.data, "test-client") assert.NotContains(t, file.data, "test-client") loaded, err := store.Load("test-client") require.NoError(t, err) assert.Equal(t, "test-token", loaded.AccessToken) assert.Equal(t, "keyring: test", store.String()) } func TestSecureStoreFallsBackToFileWhenProbeFails(t *testing.T) { kr := newMockProberStore[Token]("keyring: test", false) file := newMockStore[Token]("file: test") store := NewSecureStore[Token](kr, file) tok := Token{ AccessToken: "test-token", ClientID: "test-client", ExpiresAt: time.Now().Add(1 * time.Hour), } require.NoError(t, store.Save(tok.ClientID, tok)) // Should be in file, not keyring assert.Contains(t, file.data, "test-client") assert.NotContains(t, kr.data, "test-client") assert.Equal(t, "file: test", store.String()) } func TestSecureStoreFallsBackWhenKrNotProber(t *testing.T) { // kr does not implement Prober, should fall back to file kr := newMockStore[Token]("keyring: test") file := newMockStore[Token]("file: test") store := NewSecureStore[Token](kr, file) assert.Equal(t, "file: test", store.String()) } func TestSecureStoreDelete(t *testing.T) { kr := newMockProberStore[Token]("keyring: test", true) file := newMockStore[Token]("file: test") store := NewSecureStore[Token](kr, file) tok := Token{ AccessToken: "test-token", ClientID: "test-client", } require.NoError(t, store.Save(tok.ClientID, tok)) require.NoError(t, store.Delete("test-client")) _, err := store.Load("test-client") assert.ErrorIs(t, err, ErrNotFound) } // TestDefaultTokenSecureStoreRoundTrip verifies the happy path: with a working // keyring, Save writes AES-256-GCM ciphertext (v1: prefix) to filePath+".enc", // Load returns the identical token, and Delete makes Load return ErrNotFound. func TestDefaultTokenSecureStoreRoundTrip(t *testing.T) { keyring.MockInit() // avoid touching the real OS keyring plainPath := filepath.Join(t.TempDir(), "credentials.json") store := DefaultTokenSecureStore("test-service", plainPath) tok := Token{ AccessToken: "secret-access-token", RefreshToken: "secret-refresh-token", TokenType: "Bearer", ExpiresAt: time.Now().Add(1 * time.Hour).Truncate(time.Second), ClientID: "test-client", } require.NoError(t, store.Save(tok.ClientID, tok)) loaded, err := store.Load("test-client") require.NoError(t, err) assert.Equal(t, tok.AccessToken, loaded.AccessToken) assert.Equal(t, tok.RefreshToken, loaded.RefreshToken) assert.Equal(t, tok.TokenType, loaded.TokenType) assert.Equal(t, tok.ClientID, loaded.ClientID) assert.True(t, tok.ExpiresAt.Equal(loaded.ExpiresAt)) // The encrypted file must exist and contain only v1:-prefixed ciphertext. raw, err := os.ReadFile(plainPath + ".enc") require.NoError(t, err) assert.Contains(t, string(raw), `"v1:`) assert.NotContains(t, string(raw), "secret-access-token") assert.NotContains(t, string(raw), "secret-refresh-token") // No plaintext fallback file may be created. _, err = os.Stat(plainPath) assert.ErrorIs(t, err, os.ErrNotExist) require.NoError(t, store.Delete("test-client")) _, err = store.Load("test-client") assert.ErrorIs(t, err, ErrNotFound) } // TestDefaultTokenSecureStoreFallbackWithoutKeyring verifies the CI/headless // path: when the OS keyring is unavailable, the store falls back to the // plaintext file and Save/Load still succeed. func TestDefaultTokenSecureStoreFallbackWithoutKeyring(t *testing.T) { keyring.MockInitWithError(errors.New("keyring unavailable")) t.Cleanup(keyring.MockInit) // restore a working mock for later tests plainPath := filepath.Join(t.TempDir(), "credentials.json") store := DefaultTokenSecureStore("test-service", plainPath) tok := Token{ AccessToken: "fallback-token", ClientID: "test-client", } require.NoError(t, store.Save(tok.ClientID, tok)) assert.Equal(t, "file: "+plainPath, store.String()) loaded, err := store.Load("test-client") require.NoError(t, err) assert.Equal(t, "fallback-token", loaded.AccessToken) // Plaintext file exists, encrypted file does not. raw, err := os.ReadFile(plainPath) require.NoError(t, err) assert.True(t, strings.Contains(string(raw), "fallback-token")) _, err = os.Stat(plainPath + ".enc") assert.ErrorIs(t, err, os.ErrNotExist) } // Format-stability fixture: a fixed master key and a credentials.json.enc // file in the "v1:" AES-256-GCM format. They must remain decryptable so // users do not lose their stored tokens when upgrading tea. const ( fixtureMasterKeyB64 = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" fixtureEncFile = `{ "data": { "fixture-login": "v1:KallKg6+rJ3Sbxf6Kz1E5yF9bRgqq0Of00ZSctEY2Dem6qpm2wt9RdpCqSMdoX9AQ6/u9ujuC4a0LPb1n3ryXm0EJGrFpXHff0ukpatB1OZhdYlgcbuA8EFpPF/rSgN1hMXOXYQFn64r3iIEaXkgW69s887RNLbaxXALy3o7qvzmEWXuTPtEy3x+J4O6pmbDusvqgVWrOLPT9A1fSJnXWcViUcG13JF0X36NFPc149hsf1S0OUB2Uwn3hVl8jIISaw==" } }` ) // TestDefaultTokenSecureStoreReadsFixtureData verifies on-disk format // compatibility: the AES-256-GCM "v1:" ciphertext format (as produced by the // original SDK implementation) with the master key in the keyring is // decrypted correctly. func TestDefaultTokenSecureStoreReadsFixtureData(t *testing.T) { keyring.MockInit() require.NoError(t, keyring.Set("tea-cli", masterKeyUser, fixtureMasterKeyB64)) plainPath := filepath.Join(t.TempDir(), "credentials.json") require.NoError(t, os.WriteFile(plainPath+".enc", []byte(fixtureEncFile), 0o600)) store := DefaultTokenSecureStore("tea-cli", plainPath) loaded, err := store.Load("fixture-login") require.NoError(t, err) assert.Equal(t, "fixture-access-token", loaded.AccessToken) assert.Equal(t, "fixture-refresh-token", loaded.RefreshToken) assert.Equal(t, "Bearer", loaded.TokenType) assert.Equal(t, "fixture-login", loaded.ClientID) assert.True(t, loaded.ExpiresAt.Equal(time.Date(2027, 1, 2, 3, 4, 5, 0, time.UTC))) }