package auth import ( "context" "database/sql" "errors" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) // newTestRefreshStore opens an isolated in-memory SQLite database, applies the // embedded migrations, and returns a store over it. It returns the *sql.DB too // so tests can assert what was actually persisted. func newTestRefreshStore(t *testing.T) (*RefreshStore, *sql.DB) { t.Helper() ctx := context.Background() sqlDB, err := db.Open(ctx, ":memory:", nil) if err != nil { t.Fatalf("open in-memory db: %v", err) } t.Cleanup(func() { _ = sqlDB.Close() }) if err := db.MigrateUp(ctx, sqlDB, nil); err != nil { t.Fatalf("migrate up: %v", err) } store, err := NewRefreshStore(ctx, sqlDB, nil) if err != nil { t.Fatalf("new refresh store: %v", err) } return store, sqlDB } func TestRefreshIssueStoresHashOnly(t *testing.T) { store, sqlDB := newTestRefreshStore(t) ctx := context.Background() token, expires, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) if err != nil { t.Fatalf("issue: %v", err) } if token == "" { t.Fatal("issued token is empty") } if !expires.After(time.Now()) { t.Fatalf("expiry %v is not in the future", expires) } var hash, userID, role string if err := sqlDB.QueryRowContext(ctx, "SELECT token_hash, user_id, role FROM refresh_tokens").Scan(&hash, &userID, &role); err != nil { t.Fatalf("select stored row: %v", err) } if hash == token { t.Fatal("refresh token stored in plaintext") } if want := hashRefreshToken(token); hash != want { t.Fatalf("stored hash = %q, want sha256 %q", hash, want) } if userID != "admin" || role != string(RoleOwner) { t.Fatalf("stored row = (%q, %q), want (admin, owner)", userID, role) } } func TestRefreshRotateReplacesToken(t *testing.T) { store, _ := newTestRefreshStore(t) ctx := context.Background() old, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) if err != nil { t.Fatalf("issue: %v", err) } grant, newToken, err := store.Rotate(ctx, old, RotateOptions{TTL: time.Hour, Rotate: true}) if err != nil { t.Fatalf("rotate: %v", err) } if newToken == "" || newToken == old { t.Fatalf("rotated token = %q, want a fresh non-empty token", newToken) } if grant.UserID != "admin" || grant.Role != RoleOwner { t.Fatalf("grant = %+v, want admin/owner", grant) } if !grant.ExpiresAt.After(time.Now()) { t.Fatalf("rotated expiry %v is not in the future", grant.ExpiresAt) } // The old token is single-use: replaying it must fail closed. if _, _, err := store.Rotate(ctx, old, RotateOptions{TTL: time.Hour, Rotate: true}); !errors.Is(err, ErrInvalidGrant) { t.Fatalf("replay old token err = %v, want ErrInvalidGrant", err) } // The replacement is live. if _, _, err := store.Rotate(ctx, newToken, RotateOptions{TTL: time.Hour, Rotate: true}); err != nil { t.Fatalf("rotate replacement: %v", err) } } func TestRefreshRotateNoRotationKeepsToken(t *testing.T) { store, _ := newTestRefreshStore(t) ctx := context.Background() token, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) if err != nil { t.Fatalf("issue: %v", err) } if _, newToken, err := store.Rotate(ctx, token, RotateOptions{Rotate: false}); err != nil || newToken != "" { t.Fatalf("no-rotate = (%q, %v), want empty token and nil error", newToken, err) } // The presented token must still be usable. if _, _, err := store.Rotate(ctx, token, RotateOptions{TTL: time.Hour, Rotate: true}); err != nil { t.Fatalf("rotate after no-rotate: %v", err) } } func TestRefreshRotateInvalidGrant(t *testing.T) { store, sqlDB := newTestRefreshStore(t) ctx := context.Background() expired, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) if err != nil { t.Fatalf("issue expired: %v", err) } past := time.Now().UTC().Add(-time.Minute).Format(refreshTimeLayout) if _, err := sqlDB.ExecContext(ctx, "UPDATE refresh_tokens SET expires_at = ? WHERE token_hash = ?", past, hashRefreshToken(expired)); err != nil { t.Fatalf("age token: %v", err) } revoked, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) if err != nil { t.Fatalf("issue revoked: %v", err) } if ok, err := store.Revoke(ctx, revoked); err != nil || !ok { t.Fatalf("revoke = (%v, %v), want (true, nil)", ok, err) } cases := map[string]string{ "empty": "", "garbage": "not-a-real-token", "expired": expired, "revoked": revoked, } for name, token := range cases { t.Run(name, func(t *testing.T) { _, _, err := store.Rotate(ctx, token, RotateOptions{TTL: time.Hour, Rotate: true}) if !errors.Is(err, ErrInvalidGrant) { t.Fatalf("rotate(%q) err = %v, want ErrInvalidGrant", name, err) } }) } } func TestRefreshRevokeAll(t *testing.T) { store, _ := newTestRefreshStore(t) ctx := context.Background() first, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) if err != nil { t.Fatalf("issue first: %v", err) } second, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) if err != nil { t.Fatalf("issue second: %v", err) } n, err := store.RevokeAll(ctx) if err != nil { t.Fatalf("revoke all: %v", err) } if n != 2 { t.Fatalf("revoked %d rows, want 2", n) } for _, token := range []string{first, second} { if _, _, err := store.Rotate(ctx, token, RotateOptions{TTL: time.Hour, Rotate: true}); !errors.Is(err, ErrInvalidGrant) { t.Fatalf("rotate after revoke-all err = %v, want ErrInvalidGrant", err) } } } func TestRefreshRevokeByUser(t *testing.T) { store, _ := newTestRefreshStore(t) ctx := context.Background() adminToken, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) if err != nil { t.Fatalf("issue admin: %v", err) } otherToken, _, err := store.Issue(ctx, "other", RoleViewer, time.Hour) if err != nil { t.Fatalf("issue other: %v", err) } n, err := store.RevokeByUser(ctx, "admin") if err != nil { t.Fatalf("revoke by user: %v", err) } if n != 1 { t.Fatalf("revoked %d rows, want 1", n) } if _, _, err := store.Rotate(ctx, adminToken, RotateOptions{TTL: time.Hour, Rotate: true}); !errors.Is(err, ErrInvalidGrant) { t.Fatalf("admin rotate after revoke err = %v, want ErrInvalidGrant", err) } if _, _, err := store.Rotate(ctx, otherToken, RotateOptions{TTL: time.Hour, Rotate: true}); err != nil { t.Fatalf("other rotate must be unaffected: %v", err) } } func TestRefreshRevokeIsIdempotent(t *testing.T) { store, _ := newTestRefreshStore(t) ctx := context.Background() token, _, err := store.Issue(ctx, "admin", RoleOwner, time.Hour) if err != nil { t.Fatalf("issue: %v", err) } if ok, err := store.Revoke(ctx, token); err != nil || !ok { t.Fatalf("first revoke = (%v, %v), want (true, nil)", ok, err) } if ok, err := store.Revoke(ctx, token); err != nil || ok { t.Fatalf("second revoke = (%v, %v), want (false, nil)", ok, err) } if ok, err := store.Revoke(ctx, "unknown-token"); err != nil || ok { t.Fatalf("unknown revoke = (%v, %v), want (false, nil)", ok, err) } } func TestRefreshSchemaIdempotent(t *testing.T) { ctx := context.Background() sqlDB, err := db.Open(ctx, ":memory:", nil) if err != nil { t.Fatalf("open db: %v", err) } t.Cleanup(func() { _ = sqlDB.Close() }) if err := db.MigrateUp(ctx, sqlDB, nil); err != nil { t.Fatalf("first migrate up: %v", err) } if err := db.MigrateUp(ctx, sqlDB, nil); err != nil { t.Fatalf("second migrate up: %v", err) } var name string if err := sqlDB.QueryRowContext(ctx, "SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'").Scan(&name); err != nil { t.Fatalf("refresh_tokens table missing: %v", err) } // The store constructor re-issues the same DDL; it must be a no-op. for i := 0; i < 2; i++ { if _, err := NewRefreshStore(ctx, sqlDB, nil); err != nil { t.Fatalf("NewRefreshStore #%d: %v", i+1, err) } } // Down then up must also round-trip. if err := db.MigrateDown(ctx, sqlDB, 1, nil); err != nil { t.Fatalf("migrate down: %v", err) } if err := db.MigrateUp(ctx, sqlDB, nil); err != nil { t.Fatalf("migrate up after down: %v", err) } }