@ -5,14 +5,21 @@ package cache
import (
"context"
"database/sql/driver"
stderrors "errors"
"fmt"
"sync"
"testing"
"time"
"github.com/hashicorp/boundary/api/aliases"
"github.com/hashicorp/boundary/api/authtokens"
"github.com/hashicorp/boundary/api/sessions"
"github.com/hashicorp/boundary/api/targets"
cachedb "github.com/hashicorp/boundary/internal/clientcache/internal/db"
"github.com/hashicorp/boundary/internal/db"
"github.com/hashicorp/go-dbw"
"github.com/hashicorp/go-hclog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
@ -610,7 +617,7 @@ func TestRepository_LookupToken(t *testing.T) {
} )
}
func TestRepository_lookupUp ser( t * testing . T ) {
func TestRepository_lookupUp U ser( t * testing . T ) {
ctx := context . Background ( )
s , err := cachedb . Open ( ctx )
require . NoError ( t , err )
@ -650,6 +657,250 @@ func TestRepository_lookupUpser(t *testing.T) {
assert . NoError ( t , err )
assert . Equal ( t , & user { Id : at . UserId , Address : addr } , u )
} )
t . Run ( "soft-deleted" , func ( t * testing . T ) {
at2 := & authtokens . AuthToken {
Id : "at_2" ,
Token : "at_2_token" ,
UserId : "u_2" ,
ExpirationTime : time . Now ( ) . Add ( 1 * time . Minute ) , // not expired is required for this test
}
kt2 := KeyringToken {
TokenName : "t2" ,
KeyringType : "k2" ,
AuthTokenId : at2 . Id ,
}
addr2 := "address2"
boundaryAuthTokens2 := [ ] * authtokens . AuthToken { at2 }
atMap2 := map [ ringToken ] * authtokens . AuthToken {
{ kt2 . KeyringType , kt2 . TokenName } : at2 ,
}
m := & sync . Map { }
r2 , err := NewRepository ( ctx , s , m , mapBasedAuthTokenKeyringLookup ( atMap2 ) , sliceBasedAuthTokenBoundaryReader ( boundaryAuthTokens2 ) )
require . NoError ( t , err )
assert . NoError ( t , r2 . AddKeyringToken ( ctx , addr2 , kt2 ) )
rs , err := NewRefreshService ( ctx , r2 , hclog . NewNullLogger ( ) , 0 , 0 )
require . NoError ( t , err )
retTargets := [ ] * targets . Target {
target ( "1" ) ,
target ( "2" ) ,
target ( "3" ) ,
target ( "4" ) ,
}
opts := [ ] Option {
WithAliasRetrievalFunc ( testResolvableAliasStaticResourceRetrievalFunc ( testStaticResourceRetrievalFuncForId [ * aliases . Alias ] ( t , nil , nil ) ) ) ,
WithSessionRetrievalFunc ( testSessionStaticResourceRetrievalFunc ( testStaticResourceRetrievalFunc [ * sessions . Session ] ( t , nil , nil ) ) ) ,
WithTargetRetrievalFunc ( testTargetStaticResourceRetrievalFunc ( testStaticResourceRetrievalFunc [ * targets . Target ] ( t ,
[ ] [ ] * targets . Target {
retTargets [ : 3 ] ,
retTargets [ 3 : ] ,
} ,
[ ] [ ] string {
nil ,
{ retTargets [ 0 ] . Id , retTargets [ 1 ] . Id } ,
} ,
) ) ) ,
}
assert . NoError ( t , rs . RefreshForSearch ( ctx , at2 . Id , Targets , opts ... ) )
// Now load up a few resources and a token, and trying again should
// see the RefreshForSearch update more fields.
assert . NoError ( t , rs . Refresh ( ctx , opts ... ) )
cachedTargets , err := r . ListTargets ( ctx , at2 . Id )
assert . NoError ( t , err )
assert . ElementsMatch ( t , retTargets [ : 3 ] , cachedTargets . Targets )
// should be found in cache (user_active)
u2 , err := r2 . lookupUser ( ctx , at2 . UserId )
assert . NoError ( t , err )
assert . Equal ( t , & user { Id : at2 . UserId , Address : addr2 } , u2 )
u2 , err = r2 . lookupUser ( ctx , at2 . UserId )
assert . NoError ( t , err )
assert . Equal ( t , & user { Id : at2 . UserId , Address : addr2 } , u2 )
// should be found in underlying user table as well
tu , err := testLookupUser ( t , s , at2 . UserId )
assert . NoError ( t , err )
assert . Equal ( t , & testUser { Id : at2 . UserId , Address : addr2 , DeletedAt : infinityValue } , tu )
// there better be some refresh tokens
tks , err := r2 . listRefreshTokens ( ctx , u2 )
assert . NoError ( t , err )
assert . NotEmpty ( t , tks )
// now delete the user's auth_token and be sure the user is still found
// in the cache (table == "user" and not in "user_active")
err = r2 . deleteKeyringToken ( ctx , kt2 )
require . NoError ( t , err )
currentTks , err := r2 . listTokens ( ctx , u2 )
require . NoError ( t , err )
assert . Empty ( t , currentTks )
// should no longer be an active user
u2 , err = r2 . lookupUser ( ctx , tu . Id )
assert . NoError ( t , err )
assert . Empty ( t , u2 )
// should still be found in underlying user table
tu , err = testLookupUser ( t , s , tu . Id )
assert . NoError ( t , err )
assert . Equal ( t , & testUser { Id : tu . Id , Address : tu . Address , DeletedAt : tu . DeletedAt } , tu )
} )
t . Run ( "hard-deleted" , func ( t * testing . T ) {
at3 := & authtokens . AuthToken {
Id : "at_3" ,
Token : "at_3_token" ,
UserId : "u_3" ,
ExpirationTime : time . Now ( ) . Add ( 1 * time . Minute ) , // not expired is required for this test
}
kt3 := KeyringToken {
TokenName : "t3" ,
KeyringType : "k3" ,
AuthTokenId : at3 . Id ,
}
addr3 := "address3"
boundaryAuthTokens3 := [ ] * authtokens . AuthToken { at3 }
atMap3 := map [ ringToken ] * authtokens . AuthToken {
{ kt3 . KeyringType , kt3 . TokenName } : at3 ,
}
m := & sync . Map { }
r3 , err := NewRepository ( ctx , s , m , mapBasedAuthTokenKeyringLookup ( atMap3 ) , sliceBasedAuthTokenBoundaryReader ( boundaryAuthTokens3 ) )
require . NoError ( t , err )
assert . NoError ( t , r3 . AddKeyringToken ( ctx , addr3 , kt3 ) )
// should be found in cache (user_active)
u3 , err := r3 . lookupUser ( ctx , at3 . UserId )
assert . NoError ( t , err )
assert . Equal ( t , & user { Id : at3 . UserId , Address : addr3 } , u3 )
u3 , err = r3 . lookupUser ( ctx , at3 . UserId )
assert . NoError ( t , err )
assert . Equal ( t , & user { Id : at3 . UserId , Address : addr3 } , u3 )
// should be found in underlying user table as well
tu , err := testLookupUser ( t , s , at3 . UserId )
assert . NoError ( t , err )
assert . Equal ( t , & testUser { Id : at3 . UserId , Address : addr3 , DeletedAt : infinityValue } , tu )
// there better be some refresh tokens
tks , err := r3 . listRefreshTokens ( ctx , u3 )
assert . NoError ( t , err )
assert . Empty ( t , tks )
// now delete the user's auth_token and be sure the user is not found
// in the cache (not in either the "user" or "user_active" tables)
err = r3 . deleteKeyringToken ( ctx , kt3 )
require . NoError ( t , err )
currentTks , err := r3 . listTokens ( ctx , u3 )
require . NoError ( t , err )
assert . Empty ( t , currentTks )
// should no longer be an active user
u3 , err = r3 . lookupUser ( ctx , tu . Id )
assert . NoError ( t , err )
assert . Empty ( t , u3 )
// should not be found in underlying user table
_ , err = testLookupUser ( t , s , tu . Id )
assert . Error ( t , err )
assert . ErrorIs ( t , err , dbw . ErrRecordNotFound )
} )
}
// infinityValue represents a time.Time that is infinity
var infinityValue = infinityDate {
Time : time . Time { } ,
IsInfinity : true ,
}
// negInfinityValue represents a time.Time that is negative infinity
var negInfinityValue = infinityDate {
Time : time . Time { } ,
IsNegInfinity : true ,
}
// infinityDate is used to represent a time.Time that can be infinity, neg
// infinity or a regular time.Time
type infinityDate struct {
Time time . Time
IsInfinity bool
IsNegInfinity bool
}
// sqliteDatetimeLayout defines the format for sqlite datetime ('YYYY-MM-DD HH:MM:SS.SSS')
const sqliteDatetimeLayout = "2006-01-02 15:04:05.999"
// Scan implements the sql.Scanner interface for infinityDate
func ( d * infinityDate ) Scan ( value any ) error {
switch v := value . ( type ) {
case string :
if v == "infinity" {
d . IsInfinity = true
d . IsNegInfinity = false
return nil
} else if v == "-infinity" {
d . IsNegInfinity = true
d . IsInfinity = false
return nil
} else {
parsedTime , err := time . Parse ( sqliteDatetimeLayout , v )
if err != nil {
return err
}
d . Time = parsedTime
d . IsInfinity = false
d . IsNegInfinity = false
return nil
}
case time . Time :
d . Time = v
d . IsInfinity = false
d . IsNegInfinity = false
return nil
}
return stderrors . New ( "unsupported data type for Date" )
}
// Value implements the driver.Valuer interface for infinityDate
func ( d infinityDate ) Value ( ) ( driver . Value , error ) {
if d . IsInfinity {
return "infinity" , nil
} else if d . IsNegInfinity {
return "-infinity" , nil
}
return d . Time . Format ( sqliteDatetimeLayout ) , nil
}
// testUser is used by testLookupUser to lookup a user from the database and
// supports returning the user's DeletedAt time (soft delete).
type testUser struct {
Id string
Address string
DeletedAt infinityDate
}
// testLookupUser is a helper function to lookup a user from the database in the
// underlying user table.
func testLookupUser ( t * testing . T , conn any , id string ) ( * testUser , error ) {
t . Helper ( )
var rw db . Reader
switch v := conn . ( type ) {
case * db . DB :
rw = db . New ( v )
case db . Reader :
rw = v
}
u := & testUser {
Id : id ,
}
err := rw . LookupById ( context . Background ( ) , u , db . WithTable ( "user" ) )
switch {
case err == nil :
return u , nil
default :
return & testUser { } , err
}
}
func TestRepository_RemoveStaleTokens ( t * testing . T ) {
@ -863,4 +1114,110 @@ func TestUpsertUserAndAuthToken(t *testing.T) {
return nil
} )
require . NoError ( t , err )
t . Run ( "hard-and-soft-delete-oldest-user" , func ( t * testing . T ) {
boundaryAuthTokens := make ( [ ] * authtokens . AuthToken , 0 , usersLimit )
atMap := map [ ringToken ] * authtokens . AuthToken { }
m := & sync . Map { }
// create usersLimit users to simulate the case where the user limit is
// reached. The Tx is required because upsertUserAndAuthToken requires
// an inflight transaction.
_ , err = rw . DoTx ( ctx , 1 , db . ExpBackoff { } , func ( txReader db . Reader , txWriter db . Writer ) error {
for i := 1 ; i <= usersLimit ; i ++ {
u := & user {
Id : fmt . Sprintf ( "u_%d" , i ) ,
Address : fmt . Sprintf ( "address_%d" , i ) ,
}
at := & authtokens . AuthToken {
Id : fmt . Sprintf ( "at_%d" , i ) ,
Token : fmt . Sprintf ( "at_%d_token" , i ) ,
UserId : u . Id ,
}
boundaryAuthTokens = append ( boundaryAuthTokens , at )
atMap [ ringToken { fmt . Sprintf ( "k_%d" , i ) , fmt . Sprintf ( "t_%d" , i ) } ] = at
err := upsertUserAndAuthToken ( ctx , txReader , txWriter , u . Address , at )
require . NoError ( t , err )
}
return nil
} )
// verify that all the initial users were added
repo , err := NewRepository ( ctx , s , m , mapBasedAuthTokenKeyringLookup ( atMap ) , sliceBasedAuthTokenBoundaryReader ( boundaryAuthTokens ) )
require . NoError ( t , err )
for i := 1 ; i <= usersLimit ; i ++ {
userId := fmt . Sprintf ( "u_%d" , i )
foundUser , err := repo . lookupUser ( ctx , userId )
require . NoError ( t , err )
_ , err = testLookupUser ( t , s , foundUser . Id )
assert . NoError ( t , err )
}
{
// setup is done. Let's add a new user and verify that the oldest
// user is hard deleted
_ , err = rw . DoTx ( ctx , 1 , db . ExpBackoff { } , func ( txReader db . Reader , txWriter db . Writer ) error {
// add a new user, which should trigger the hard deletion of the oldest user
newUser := & user {
Id : "u_new" ,
Address : "address_new" ,
}
newUserAt := & authtokens . AuthToken {
Id : "at_new" ,
Token : "at_new_token" ,
UserId : newUser . Id ,
}
err := upsertUserAndAuthToken ( ctx , txReader , txWriter , newUser . Address , newUserAt )
require . NoError ( t , err )
return nil
} )
require . NoError ( t , err )
// verify that the oldest user was hard deleted
foundUser , err := repo . lookupUser ( ctx , "u_1" )
assert . NoError ( t , err )
assert . Empty ( t , foundUser )
foundTestUser , err := testLookupUser ( t , s , "u_1" )
assert . Error ( t , err )
assert . Equal ( t , & testUser { } , foundTestUser )
}
{
// Let's add a refresh token for the oldest user and then new user
// and verify that the oldest user is soft deleted
rt := & refreshToken {
UserId : "u_2" ,
ResourceType : "target" ,
RefreshToken : "rt_2" ,
CreateTime : time . Now ( ) . Add ( - 24 * time . Hour ) ,
UpdateTime : time . Now ( ) . Add ( - 24 * time . Hour ) ,
}
err = repo . rw . Create ( ctx , rt )
require . NoError ( t , err )
_ , err = rw . DoTx ( ctx , 1 , db . ExpBackoff { } , func ( txReader db . Reader , txWriter db . Writer ) error {
// add a new user, which should trigger the soft deletion of the oldest user
newUser := & user {
Id : "u_new_2" ,
Address : "address_new_2" ,
}
newUserAt := & authtokens . AuthToken {
Id : "at_new_2" ,
Token : "at_new_token_2" ,
UserId : newUser . Id ,
}
err := upsertUserAndAuthToken ( ctx , txReader , txWriter , newUser . Address , newUserAt )
require . NoError ( t , err )
return nil
} )
require . NoError ( t , err )
// verify that the oldest user was soft deleted
foundUser , err := repo . lookupUser ( ctx , "u_2" )
assert . NoError ( t , err )
assert . Empty ( t , foundUser )
// should not find the user in the underlying user table
foundTestUser , err := testLookupUser ( t , s , "u_2" )
assert . NoError ( t , err )
assert . NotEqual ( t , & testUser { } , foundTestUser )
}
} )
}