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