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>
221 lines
6.2 KiB
Go
221 lines
6.2 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package credstore
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func newTestFileStore(t *testing.T) (*FileStore[Token], string) {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "tokens.json")
|
|
return NewFileStore[Token](path, JSONCodec[Token]{}), path
|
|
}
|
|
|
|
func TestFileStoreSaveAndLoad(t *testing.T) {
|
|
store, _ := newTestFileStore(t)
|
|
|
|
tok := Token{
|
|
AccessToken: "test-access-token",
|
|
RefreshToken: "test-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.ClientID, loaded.ClientID)
|
|
}
|
|
|
|
func TestFileStoreLoadNotFound(t *testing.T) {
|
|
store, _ := newTestFileStore(t)
|
|
|
|
_, err := store.Load("nonexistent")
|
|
assert.ErrorIs(t, err, ErrNotFound)
|
|
}
|
|
|
|
func TestFileStoreLoadFromExistingFileNotFound(t *testing.T) {
|
|
store, _ := newTestFileStore(t)
|
|
|
|
require.NoError(t, store.Save("client-1", Token{AccessToken: "token-1", ClientID: "client-1"}))
|
|
|
|
_, err := store.Load("client-2")
|
|
assert.ErrorIs(t, err, ErrNotFound)
|
|
}
|
|
|
|
func TestFileStoreDelete(t *testing.T) {
|
|
store, _ := newTestFileStore(t)
|
|
|
|
require.NoError(t, store.Save("test-client", Token{AccessToken: "test-token", ClientID: "test-client"}))
|
|
require.NoError(t, store.Delete("test-client"))
|
|
|
|
_, err := store.Load("test-client")
|
|
assert.ErrorIs(t, err, ErrNotFound)
|
|
}
|
|
|
|
func TestFileStoreDeleteNonexistent(t *testing.T) {
|
|
store, _ := newTestFileStore(t)
|
|
|
|
// Should not error when deleting from nonexistent file
|
|
assert.NoError(t, store.Delete("nonexistent"))
|
|
}
|
|
|
|
func TestFileStoreDeletePreservesOtherClients(t *testing.T) {
|
|
store, _ := newTestFileStore(t)
|
|
|
|
for _, id := range []string{"client-1", "client-2"} {
|
|
require.NoError(t, store.Save(id, Token{AccessToken: "token-" + id, ClientID: id}))
|
|
}
|
|
|
|
require.NoError(t, store.Delete("client-1"))
|
|
|
|
loaded, err := store.Load("client-2")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "token-client-2", loaded.AccessToken)
|
|
}
|
|
|
|
func TestFileStoreConcurrentWrites(t *testing.T) {
|
|
store, _ := newTestFileStore(t)
|
|
|
|
const goroutines = 10
|
|
var wg sync.WaitGroup
|
|
|
|
wg.Add(goroutines)
|
|
for i := range goroutines {
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
|
|
tok := Token{
|
|
AccessToken: fmt.Sprintf("access-token-%d", id),
|
|
ClientID: fmt.Sprintf("client-%d", id),
|
|
}
|
|
assert.NoError(t, store.Save(tok.ClientID, tok))
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
// Verify all tokens were saved by loading each one
|
|
for i := range goroutines {
|
|
clientID := fmt.Sprintf("client-%d", i)
|
|
loaded, err := store.Load(clientID)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, fmt.Sprintf("access-token-%d", i), loaded.AccessToken)
|
|
}
|
|
|
|
// The kernel lock is released after the saves: the lock file (which
|
|
// legitimately remains on disk with flock-style locking) must be
|
|
// immediately re-lockable without hitting the timeout.
|
|
require.NoError(t, store.withFileLock(func() error { return nil }))
|
|
}
|
|
|
|
func TestFileStoreSaveEmptyClientID(t *testing.T) {
|
|
store, _ := newTestFileStore(t)
|
|
|
|
err := store.Save("", Token{AccessToken: "tok"})
|
|
assert.ErrorIs(t, err, ErrEmptyClientID)
|
|
}
|
|
|
|
func TestFileStoreFilePermissions(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("file permission test is not applicable on Windows")
|
|
}
|
|
|
|
store, path := newTestFileStore(t)
|
|
|
|
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
|
|
|
|
info, err := os.Stat(path)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
|
}
|
|
|
|
func TestFileStoreSaveCreatesParentDirectories(t *testing.T) {
|
|
nestedPath := filepath.Join(t.TempDir(), "a", "b", "c", "tokens.json")
|
|
store := NewFileStore[Token](nestedPath, JSONCodec[Token]{})
|
|
|
|
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
|
|
|
|
loaded, err := store.Load("c1")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "tok", loaded.AccessToken)
|
|
}
|
|
|
|
func TestFileStoreInvalidJSON(t *testing.T) {
|
|
store, path := newTestFileStore(t)
|
|
require.NoError(t, os.WriteFile(path, []byte("{invalid"), 0o600))
|
|
|
|
_, err := store.Load("any")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestFileStoreNullDataField(t *testing.T) {
|
|
store, path := newTestFileStore(t)
|
|
require.NoError(t, os.WriteFile(path, []byte(`{"data": null}`), 0o600))
|
|
|
|
// A null data map must read as empty, not crash.
|
|
_, err := store.Load("any")
|
|
assert.ErrorIs(t, err, ErrNotFound)
|
|
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
|
|
}
|
|
|
|
func TestFileStoreWithFileLockPropagatesErrorAndReleases(t *testing.T) {
|
|
store, _ := newTestFileStore(t)
|
|
|
|
sentinel := fmt.Errorf("sentinel failure")
|
|
err := store.withFileLock(func() error { return sentinel })
|
|
assert.ErrorIs(t, err, sentinel)
|
|
|
|
// The lock must have been released despite the error: re-acquiring
|
|
// immediately must succeed without hitting the timeout.
|
|
assert.NoError(t, store.withFileLock(func() error { return nil }))
|
|
}
|
|
|
|
func TestFileStoreSaveLeavesNoTempFile(t *testing.T) {
|
|
store, path := newTestFileStore(t)
|
|
|
|
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
|
|
|
|
_, err := os.Stat(path + ".tmp")
|
|
assert.True(t, os.IsNotExist(err), "temp file left behind after successful save")
|
|
}
|
|
|
|
func TestFileStoreDeleteAbsentKeyDoesNotRewriteFile(t *testing.T) {
|
|
store, path := newTestFileStore(t)
|
|
|
|
require.NoError(t, store.Save("c1", Token{AccessToken: "tok", ClientID: "c1"}))
|
|
before, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
|
|
// Deleting a key that is not present must be a no-op write-wise:
|
|
// other clients' data stays byte-identical on disk.
|
|
require.NoError(t, store.Delete("absent"))
|
|
after, err := os.ReadFile(path)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, string(before), string(after))
|
|
}
|
|
|
|
func TestFileStoreString(t *testing.T) {
|
|
store := NewFileStore[Token]("/path/to/tokens.json", JSONCodec[Token]{})
|
|
assert.Equal(t, "file: /path/to/tokens.json", store.String())
|
|
}
|
|
|
|
func TestFileStoreNilCodecPanics(t *testing.T) {
|
|
assert.Panics(t, func() {
|
|
NewFileStore[string]("path", nil)
|
|
})
|
|
}
|