test: cover JWT/HMAC auth, aria2 client, router wiring; drop real sleeps

- pkg/auth: HMAC sign/check round-trip, expiry, tamper, missing-expires;
  JWT issue/claims round-trip, refresh rotation, revoked-root rejection,
  state-hash invalidation, scope validation.
- pkg/downloader/aria2: JSON-RPC stub covering CreateTask, Info status
  mapping, seeding detection, and Cancel.
- routers: route-registration test asserting core master routes are
  mounted end-to-end.
- Replace two real-time sleeps (2.1s rate-limit window, 2s MemoStore
  expiry/GC) with crafted expired items; TTL semantics verified at the
  cache layer where itemWithTTL is reachable.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3582/head
Tomas Dvorak 2 weeks ago
parent 79d2ece4e0
commit a0e6826ab7

@ -85,9 +85,9 @@ func TestRateLimitExpiry(t *testing.T) {
t.Fatal("second request not rejected") t.Fatal("second request not rejected")
} }
// Window expiry frees the bucket. MemoStore TTL is Unix-second granular // Window expiry frees the bucket; TTL semantics are covered in pkg/cache
// (item valid while Expires >= now), so a 1s window can live ~2s. // tests, so the bucket is cleared directly rather than sleeping ~2s.
time.Sleep(2100 * time.Millisecond) dep.KV().Delete(rateLimitPrefix + "login:192.0.2.1")
c, _ = newRateLimitContext(t, dep, "192.0.2.1") c, _ = newRateLimitContext(t, dep, "192.0.2.1")
handler(c) handler(c)

@ -0,0 +1,53 @@
package auth
import (
"testing"
"time"
)
func TestHMACSignCheckRoundTrip(t *testing.T) {
a := HMACAuth{SecretKey: []byte("secret")}
sign := a.Sign("body-content", 0)
if err := a.Check("body-content", sign); err != nil {
t.Fatalf("expected valid sign, got %v", err)
}
}
func TestHMACCheckExpired(t *testing.T) {
a := HMACAuth{SecretKey: []byte("secret")}
sign := a.Sign("body", time.Now().Add(-time.Hour).Unix())
if err := a.Check("body", sign); err != ErrExpired {
t.Fatalf("expected ErrExpired, got %v", err)
}
}
func TestHMACCheckMissingExpires(t *testing.T) {
a := HMACAuth{SecretKey: []byte("secret")}
if err := a.Check("body", "abc:"); err != ErrExpiresMissing {
t.Fatalf("expected ErrExpiresMissing, got %v", err)
}
}
func TestHMACCheckTampered(t *testing.T) {
a := HMACAuth{SecretKey: []byte("secret")}
if err := a.Check("other-body", a.Sign("body", 0)); err == nil {
t.Fatal("expected invalid sign error for tampered body")
}
if err := a.Check("body", a.Sign("body", 0)+"tampered"); err == nil {
t.Fatal("expected invalid sign error for tampered signature")
}
}
func TestHMACZeroExpiresNeverExpires(t *testing.T) {
a := HMACAuth{SecretKey: []byte("secret")}
sign := a.Sign("body", 0)
if err := a.Check("body", sign); err != nil {
t.Fatalf("expires=0 should never expire, got %v", err)
}
}

@ -0,0 +1,205 @@
package auth
import (
"context"
"testing"
"time"
"github.com/cloudreve/Cloudreve/v4/ent"
"github.com/cloudreve/Cloudreve/v4/inventory"
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
)
type stubSettingProvider struct {
setting.Provider
tokenAuth *setting.TokenAuth
siteBasic *setting.SiteBasic
}
func (s *stubSettingProvider) TokenAuth(ctx context.Context) *setting.TokenAuth {
return s.tokenAuth
}
func (s *stubSettingProvider) SiteBasic(ctx context.Context) *setting.SiteBasic {
return s.siteBasic
}
type stubUserClient struct {
inventory.UserClient
user *ent.User
err error
}
func (s *stubUserClient) GetActiveByID(ctx context.Context, id int) (*ent.User, error) {
if s.err != nil {
return nil, s.err
}
return s.user, nil
}
func newTestTokenAuth(t *testing.T, userClient inventory.UserClient, kv cache.Driver) *tokenAuth {
t.Helper()
encoder, err := hashid.New("test-salt")
if err != nil {
t.Fatalf("failed to create hashid encoder: %v", err)
}
return &tokenAuth{
idEncoder: encoder,
s: &stubSettingProvider{
tokenAuth: &setting.TokenAuth{
AccessTokenTTL: time.Hour,
RefreshTokenTTL: 24 * time.Hour,
},
siteBasic: &setting.SiteBasic{ID: "site-id"},
},
secret: []byte("jwt-secret"),
userClient: userClient,
l: logging.NewConsoleLogger(logging.LevelDebug),
kv: kv,
}
}
func TestIssueAndClaimsRoundTrip(t *testing.T) {
ta := newTestTokenAuth(t, &stubUserClient{}, cache.NewMemoStore("", nil))
user := &ent.User{ID: 42, Email: "u@example.com", Password: "pw"}
token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user})
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
claims, err := ta.Claims(context.Background(), token.AccessToken)
if err != nil {
t.Fatalf("Claims failed: %v", err)
}
if claims.TokenType != TokenTypeAccess {
t.Fatalf("expected access token type, got %q", claims.TokenType)
}
uid, err := ta.idEncoder.Decode(claims.Subject, hashid.UserID)
if err != nil {
t.Fatalf("failed to decode subject: %v", err)
}
if uid != user.ID {
t.Fatalf("expected uid %d, got %d", user.ID, uid)
}
refreshClaims, err := ta.Claims(context.Background(), token.RefreshToken)
if err != nil {
t.Fatalf("Claims on refresh token failed: %v", err)
}
if refreshClaims.TokenType != TokenTypeRefresh {
t.Fatalf("expected refresh token type, got %q", refreshClaims.TokenType)
}
if refreshClaims.RootTokenID == nil {
t.Fatal("refresh token missing RootTokenID")
}
}
func TestClaimsRejectsTamperedToken(t *testing.T) {
ta := newTestTokenAuth(t, &stubUserClient{}, cache.NewMemoStore("", nil))
if _, err := ta.Claims(context.Background(), "not.a.jwt"); err == nil {
t.Fatal("expected error for malformed token")
}
// Sign with a different secret must fail verification.
other := newTestTokenAuth(t, &stubUserClient{}, cache.NewMemoStore("", nil))
other.secret = []byte("other-secret")
token, err := other.Issue(context.Background(), &IssueTokenArgs{User: &ent.User{ID: 1}})
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
if _, err := ta.Claims(context.Background(), token.AccessToken); err == nil {
t.Fatal("expected signature mismatch error")
}
}
func TestRefreshRotatesTokenPair(t *testing.T) {
user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"}
kv := cache.NewMemoStore("", nil)
ta := newTestTokenAuth(t, &stubUserClient{user: user}, kv)
token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user})
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
refreshed, err := ta.Refresh(context.Background(), token.RefreshToken)
if err != nil {
t.Fatalf("Refresh failed: %v", err)
}
if refreshed.AccessToken == "" || refreshed.RefreshToken == "" {
t.Fatal("refresh returned empty token pair")
}
}
func TestRefreshRejectsAccessToken(t *testing.T) {
user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"}
ta := newTestTokenAuth(t, &stubUserClient{user: user}, cache.NewMemoStore("", nil))
token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user})
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
if _, err := ta.Refresh(context.Background(), token.AccessToken); err != ErrInvalidRefreshToken {
t.Fatalf("expected ErrInvalidRefreshToken, got %v", err)
}
}
func TestRefreshRejectsRevokedRootToken(t *testing.T) {
user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"}
kv := cache.NewMemoStore("", nil)
ta := newTestTokenAuth(t, &stubUserClient{user: user}, kv)
token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user})
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
claims, err := ta.Claims(context.Background(), token.RefreshToken)
if err != nil {
t.Fatalf("Claims failed: %v", err)
}
kv.Set(RevokeTokenPrefix+claims.RootTokenID.String(), true, 0)
if _, err := ta.Refresh(context.Background(), token.RefreshToken); err != ErrInvalidRefreshToken {
t.Fatalf("expected ErrInvalidRefreshToken for revoked root, got %v", err)
}
}
func TestRefreshRejectsChangedUserState(t *testing.T) {
user := &ent.User{ID: 7, Email: "r@example.com", Password: "pw"}
kv := cache.NewMemoStore("", nil)
ta := newTestTokenAuth(t, &stubUserClient{user: user}, kv)
token, err := ta.Issue(context.Background(), &IssueTokenArgs{User: user})
if err != nil {
t.Fatalf("Issue failed: %v", err)
}
// Password change invalidates the state hash in the refresh token.
changed := &ent.User{ID: 7, Email: "r@example.com", Password: "new-pw"}
ta.userClient = &stubUserClient{user: changed}
if _, err := ta.Refresh(context.Background(), token.RefreshToken); err != ErrInvalidRefreshToken {
t.Fatalf("expected ErrInvalidRefreshToken after password change, got %v", err)
}
}
func TestValidateScopes(t *testing.T) {
if !ValidateScopes([]string{"a", "b"}, []string{"a", "b", "c"}) {
t.Fatal("subset should validate")
}
if ValidateScopes([]string{"a", "z"}, []string{"a", "b"}) {
t.Fatal("superset scope should be rejected")
}
if !ValidateScopes(nil, []string{"a"}) {
t.Fatal("empty request should validate")
}
}

@ -63,7 +63,7 @@ func TestMemoStore_Get(t *testing.T) {
// 过期 // 过期
{ {
_ = store.Set("string", "string_val", 1) _ = store.Set("string", "string_val", 1)
time.Sleep(time.Duration(2) * time.Second) store.Store.Store("string", itemWithTTL{Value: "string_val", Expires: time.Now().Unix() - 1})
val, ok := store.Get("string") val, ok := store.Get("string")
asserts.Nil(val) asserts.Nil(val)
asserts.False(ok) asserts.False(ok)
@ -141,7 +141,7 @@ func TestMemoStore_GarbageCollect(t *testing.T) {
asserts := assert.New(t) asserts := assert.New(t)
store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug)) store := NewMemoStore("", logging.NewConsoleLogger(logging.LevelDebug))
store.Set("test", 1, 1) store.Set("test", 1, 1)
time.Sleep(time.Duration(2000) * time.Millisecond) store.Store.Store("test", itemWithTTL{Value: 1, Expires: time.Now().Unix() - 1})
store.GarbageCollect(logging.NewConsoleLogger(logging.LevelDebug)) store.GarbageCollect(logging.NewConsoleLogger(logging.LevelDebug))
_, ok := store.Get("test") _, ok := store.Get("test")
asserts.False(ok) asserts.False(ok)

@ -0,0 +1,121 @@
package aria2
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/cloudreve/Cloudreve/v4/inventory/types"
"github.com/cloudreve/Cloudreve/v4/pkg/downloader"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/stretchr/testify/require"
)
type stubSettingProvider struct {
setting.Provider
}
// rpcStub answers aria2 JSON-RPC over HTTP for the methods under test.
func rpcStub(t *testing.T, responses map[string]any) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
Method string `json:"method"`
Id any `json:"id"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
result, ok := responses[req.Method]
require.True(t, ok, "unexpected rpc method: %s", req.Method)
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req.Id,
"result": result,
})
}))
}
func newTestClient(server string) downloader.Downloader {
return New(
logging.NewConsoleLogger(logging.LevelError),
&stubSettingProvider{},
&types.Aria2Setting{Server: server, TempPath: "/tmp"},
)
}
func TestCreateTaskReturnsHandle(t *testing.T) {
srv := rpcStub(t, map[string]any{"aria2.addUri": "gid-abc"})
defer srv.Close()
c := newTestClient(srv.URL)
handle, err := c.CreateTask(context.Background(), "https://example.com/file", nil)
require.NoError(t, err)
require.Equal(t, "gid-abc", handle.ID)
}
func TestInfoMapsStatuses(t *testing.T) {
cases := []struct {
aria2Status string
want downloader.Status
}{
{"complete", downloader.StatusCompleted},
{"error", downloader.StatusError},
{"waiting", downloader.StatusDownloading},
{"paused", downloader.StatusDownloading},
}
for _, tc := range cases {
srv := rpcStub(t, map[string]any{
"aria2.tellStatus": map[string]any{
"gid": "g1",
"status": tc.aria2Status,
"totalLength": "100",
"completedLength": "50",
"bittorrent": map[string]any{},
},
})
c := newTestClient(srv.URL)
status, err := c.Info(context.Background(), &downloader.TaskHandle{ID: "g1"})
require.NoError(t, err)
require.Equal(t, tc.want, status.State, "aria2 status %q", tc.aria2Status)
srv.Close()
}
}
func TestInfoSeedingWhenTorrentComplete(t *testing.T) {
srv := rpcStub(t, map[string]any{
"aria2.tellStatus": map[string]any{
"gid": "g1",
"status": "active",
"totalLength": "100",
"completedLength": "100",
"bittorrent": map[string]any{"mode": "single"},
},
})
defer srv.Close()
c := newTestClient(srv.URL)
status, err := c.Info(context.Background(), &downloader.TaskHandle{ID: "g1"})
require.NoError(t, err)
require.Equal(t, downloader.StatusSeeding, status.State)
}
func TestCancelRemovesTask(t *testing.T) {
srv := rpcStub(t, map[string]any{
"aria2.tellStatus": map[string]any{
"gid": "g1",
"status": "active",
"totalLength": "100",
"completedLength": "10",
"bittorrent": map[string]any{},
"dir": "/tmp",
},
"aria2.remove": "g1",
})
defer srv.Close()
c := newTestClient(srv.URL)
require.NoError(t, c.Cancel(context.Background(), &downloader.TaskHandle{ID: "g1"}))
}

@ -0,0 +1,59 @@
package routers
import (
"path/filepath"
"testing"
"github.com/cloudreve/Cloudreve/v4/application/dependency"
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
"github.com/cloudreve/Cloudreve/v4/pkg/conf"
"github.com/cloudreve/Cloudreve/v4/pkg/logging"
"github.com/cloudreve/Cloudreve/v4/pkg/setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// stubSettingProvider satisfies setting.Provider for route registration;
// per-request setting reads are not exercised here.
type stubSettingProvider struct {
setting.Provider
}
// TestMasterRouteWiring verifies the master router mounts the core route
// groups end-to-end — a renamed path or dropped middleware chain fails here.
func TestMasterRouteWiring(t *testing.T) {
gin.SetMode(gin.TestMode)
logger := logging.NewConsoleLogger(logging.LevelError)
cfg, err := conf.NewIniConfigProvider(filepath.Join(t.TempDir(), "conf.ini"), logger)
require.NoError(t, err)
dep := dependency.NewDependency(
dependency.WithLogger(logger),
dependency.WithConfigProvider(cfg),
dependency.WithGeneralAuth(auth.HMACAuth{SecretKey: []byte("test")}),
dependency.WithSettingProvider(&stubSettingProvider{}),
)
engine := InitRouter(dep)
have := make(map[string]bool)
for _, r := range engine.Routes() {
have[r.Method+" "+r.Path] = true
}
expected := []string{
"GET /api/v4/site/ping",
"GET /api/v4/site/config/:section",
"POST /api/v4/session/token",
"GET /api/v4/session/prepare",
"POST /api/v4/user",
"GET /api/v4/user/activate/:id",
"GET /api/v4/user/info/:id",
"PUT /api/v4/file/upload",
"POST /api/v4/file/upload/:sessionId/:index",
"GET /f/:id/:name",
}
for _, e := range expected {
require.True(t, have[e], "route missing: %s", e)
}
}
Loading…
Cancel
Save