- 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
parent
79d2ece4e0
commit
a0e6826ab7
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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…
Reference in new issue