mirror of
https://gitea.com/gitea/tea.git
synced 2026-08-05 23:07:39 +02:00
refactor(credstore): embed credential store and drop sdk-go dependency
- Embed the minimal credstore subset used by tea (SecureStore, EncryptedFileStore, KeyringStore, FileStore) as modules/credstore so external SDK renames can no longer break the build - Keep the on-disk format fully compatible: AES-256-GCM values with the v1: prefix, credentials.json / credentials.json.enc paths, and the Token JSON field names are unchanged, verified by a ciphertext fixture generated with sdk-go v1.1.0 - Store the keyring master key under a tea-owned account name - Reuse the existing kernel-level filelock module instead of the upstream lockfile protocol, removing a stale-lock race - Cover roundtrip, keyring-unavailable fallback, and fixture decryption with tests using a mocked keyring - Remove github.com/go-signet/sdk-go and promote github.com/zalando/go-keyring to a direct dependency Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
// 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)))
|
||||
}
|
||||
Reference in New Issue
Block a user