mirror of
https://gitea.com/gitea/tea.git
synced 2026-08-05 23:07:39 +02:00
f6d939a8df
- 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>
243 lines
7.5 KiB
Go
243 lines
7.5 KiB
Go
// 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)))
|
|
}
|