Files
gitea-tea/modules/credstore/secure_store_test.go
T
Bo-Yi Wu f6d939a8df 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>
2026-08-03 21:59:07 +08:00

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)))
}