@@ -21,6 +21,20 @@ func TestMain(m *testing.M) {
2121 os .Exit (m .Run ())
2222}
2323
24+ // skipIfCannotDenyDirWrite skips tests that rely on removing write permission
25+ // from a directory to force a write failure: the trick is a no-op on Windows
26+ // (permissions work differently) and on Unix when running as root (root
27+ // bypasses permission checks).
28+ func skipIfCannotDenyDirWrite (t * testing.T ) {
29+ t .Helper ()
30+ if runtime .GOOS == "windows" {
31+ t .Skip ("directory write-permission bits are not enforced the same way on Windows" )
32+ }
33+ if os .Geteuid () == 0 {
34+ t .Skip ("Skipping permission test when running as root" )
35+ }
36+ }
37+
2438func TestAWSFileManager_WriteCredentials (t * testing.T ) {
2539 tmp := t .TempDir ()
2640 m := & AWSFileManager {baseDir : tmp }
@@ -1030,3 +1044,230 @@ func TestNewAWSFileManager_WithRealm(t *testing.T) {
10301044 })
10311045 }
10321046}
1047+
1048+ // TestWithFileLock_WrapsErrCacheLocked verifies that withFileLock wraps ErrCacheLocked with the package's ErrFileLockTimeout sentinel and never invokes fn when the lock cannot be acquired.
1049+ // Using a path whose ".lock" sibling lives under a directory that was never created makes the underlying open call fail immediately with ENOENT, since O_CREATE cannot materialize the missing parent directory — no waiting or real timeout involved, on either Unix or Windows.
1050+ func TestWithFileLock_WrapsErrCacheLocked (t * testing.T ) {
1051+ if runtime .GOOS == "windows" {
1052+ // Windows uses a best-effort no-op FileLock (see pkg/cache/filelock_windows.go)
1053+ // that never returns ErrCacheLocked, so this branch cannot be exercised there.
1054+ t .Skip ("Windows FileLock is a no-op and never reports a lock timeout" )
1055+ }
1056+
1057+ path := filepath .Join (t .TempDir (), "missing-subdir" , "credentials" )
1058+
1059+ fnCalled := false
1060+ err := withFileLock (context .Background (), path , func () error {
1061+ fnCalled = true
1062+ return nil
1063+ })
1064+
1065+ require .Error (t , err )
1066+ assert .ErrorIs (t , err , ErrFileLockTimeout )
1067+ assert .False (t , fnCalled , "fn must not run when the lock cannot be acquired" )
1068+ }
1069+
1070+ // TestAWSFileManager_WriteCredentials_WithExpiration verifies that a non-empty
1071+ // Expiration is persisted as the section comment, which is the fallback used
1072+ // to determine credential validity when keychain access is unavailable (e.g.
1073+ // inside Docker containers).
1074+ func TestAWSFileManager_WriteCredentials_WithExpiration (t * testing.T ) {
1075+ tmp := t .TempDir ()
1076+ m := & AWSFileManager {baseDir : tmp }
1077+
1078+ creds := & types.AWSCredentials {AccessKeyID : "AKIA123" , SecretAccessKey : "secret" , Expiration : "2099-01-01T00:00:00Z" }
1079+ require .NoError (t , m .WriteCredentials ("prov" , "dev" , creds ))
1080+
1081+ cfg , err := ini .Load (m .GetCredentialsPath ("prov" ))
1082+ require .NoError (t , err )
1083+ sec := cfg .Section ("dev" )
1084+ // ini re-serializes comments with a leading "; " marker, so assert on the
1085+ // substring rather than exact equality with what was originally set.
1086+ assert .Contains (t , sec .Comment , "atmos: expiration=2099-01-01T00:00:00Z" )
1087+ }
1088+
1089+ // TestAWSFileManager_WriteCredentials_LoadFailure verifies that a non-ENOENT
1090+ // failure while loading the existing credentials file (e.g. the path is
1091+ // actually a directory) is surfaced as ErrLoadCredentialsFile and does not
1092+ // fall back to treating it as a missing file.
1093+ func TestAWSFileManager_WriteCredentials_LoadFailure (t * testing.T ) {
1094+ tmp := t .TempDir ()
1095+ m := & AWSFileManager {baseDir : tmp }
1096+
1097+ credsPath := m .GetCredentialsPath ("prov" )
1098+ require .NoError (t , os .MkdirAll (credsPath , PermissionRWX )) // Directory in place of the file makes ini.Load fail non-ENOENT.
1099+
1100+ err := m .WriteCredentials ("prov" , "dev" , & types.AWSCredentials {AccessKeyID : "AKIA123" , SecretAccessKey : "secret" })
1101+ require .Error (t , err )
1102+ assert .ErrorIs (t , err , ErrLoadCredentialsFile )
1103+ }
1104+
1105+ // TestAWSFileManager_WriteConfig_LoadFailure mirrors the credentials case for
1106+ // WriteConfig's non-ENOENT ini.Load failure branch.
1107+ func TestAWSFileManager_WriteConfig_LoadFailure (t * testing.T ) {
1108+ tmp := t .TempDir ()
1109+ m := & AWSFileManager {baseDir : tmp }
1110+
1111+ configPath := m .GetConfigPath ("prov" )
1112+ require .NoError (t , os .MkdirAll (configPath , PermissionRWX ))
1113+
1114+ err := m .WriteConfig ("prov" , "dev" , "us-east-1" , "json" )
1115+ require .Error (t , err )
1116+ assert .ErrorIs (t , err , ErrLoadConfigFile )
1117+ }
1118+
1119+ // TestAWSFileManager_WriteCredentials_SaveFailure verifies that cfg.SaveTo
1120+ // failures (e.g. an unwritable target directory) are surfaced as
1121+ // ErrWriteCredentialsFile.
1122+ func TestAWSFileManager_WriteCredentials_SaveFailure (t * testing.T ) {
1123+ skipIfCannotDenyDirWrite (t )
1124+
1125+ tmp := t .TempDir ()
1126+ m := & AWSFileManager {baseDir : tmp }
1127+
1128+ credsPath := m .GetCredentialsPath ("prov" )
1129+ credsDir := filepath .Dir (credsPath )
1130+ require .NoError (t , os .MkdirAll (credsDir , PermissionRWX ))
1131+ // Pre-create the sibling lock file so lock acquisition (which also needs to
1132+ // create a file in this directory) still succeeds once the directory is
1133+ // made read-only below; only the SaveTo of the new credentials file itself
1134+ // should be blocked.
1135+ require .NoError (t , os .WriteFile (credsPath + ".lock" , nil , PermissionRW ))
1136+ require .NoError (t , os .Chmod (credsDir , 0o555 )) // Read-only: blocks creating the new credentials file.
1137+ t .Cleanup (func () { _ = os .Chmod (credsDir , PermissionRWX ) })
1138+
1139+ err := m .WriteCredentials ("prov" , "dev" , & types.AWSCredentials {AccessKeyID : "AKIA123" , SecretAccessKey : "secret" })
1140+ require .Error (t , err )
1141+ assert .ErrorIs (t , err , ErrWriteCredentialsFile )
1142+ }
1143+
1144+ // TestAWSFileManager_WriteConfig_SaveFailure mirrors the credentials case for
1145+ // WriteConfig's cfg.SaveTo failure branch.
1146+ func TestAWSFileManager_WriteConfig_SaveFailure (t * testing.T ) {
1147+ skipIfCannotDenyDirWrite (t )
1148+
1149+ tmp := t .TempDir ()
1150+ m := & AWSFileManager {baseDir : tmp }
1151+
1152+ configPath := m .GetConfigPath ("prov" )
1153+ configDir := filepath .Dir (configPath )
1154+ require .NoError (t , os .MkdirAll (configDir , PermissionRWX ))
1155+ require .NoError (t , os .WriteFile (configPath + ".lock" , nil , PermissionRW ))
1156+ require .NoError (t , os .Chmod (configDir , 0o555 ))
1157+ t .Cleanup (func () { _ = os .Chmod (configDir , PermissionRWX ) })
1158+
1159+ err := m .WriteConfig ("prov" , "dev" , "us-east-1" , "json" )
1160+ require .Error (t , err )
1161+ assert .ErrorIs (t , err , ErrWriteConfigFile )
1162+ }
1163+
1164+ // TestAWSFileManager_RemoveConfigProfile_LoadFailure verifies the non-ENOENT
1165+ // ini.Load failure branch inside RemoveConfigProfile (distinct from the "file
1166+ // vanished after the existence check" race branch).
1167+ func TestAWSFileManager_RemoveConfigProfile_LoadFailure (t * testing.T ) {
1168+ tmp := t .TempDir ()
1169+ m := & AWSFileManager {baseDir : tmp }
1170+
1171+ configPath := m .GetConfigPath ("prov" )
1172+ require .NoError (t , os .MkdirAll (configPath , PermissionRWX )) // Directory in place of the file.
1173+
1174+ err := m .RemoveConfigProfile (context .Background (), "prov" , "dev" )
1175+ require .Error (t , err )
1176+ assert .ErrorIs (t , err , ErrRemoveProfile )
1177+ }
1178+
1179+ // TestAWSFileManager_RemoveCredentialsProfile_LoadFailure mirrors the config
1180+ // case for RemoveCredentialsProfile's non-ENOENT ini.Load failure branch.
1181+ func TestAWSFileManager_RemoveCredentialsProfile_LoadFailure (t * testing.T ) {
1182+ tmp := t .TempDir ()
1183+ m := & AWSFileManager {baseDir : tmp }
1184+
1185+ credsPath := m .GetCredentialsPath ("prov" )
1186+ require .NoError (t , os .MkdirAll (credsPath , PermissionRWX ))
1187+
1188+ err := m .RemoveCredentialsProfile (context .Background (), "prov" , "dev" )
1189+ require .Error (t , err )
1190+ assert .ErrorIs (t , err , ErrRemoveProfile )
1191+ }
1192+
1193+ // TestAWSFileManager_RemoveConfigProfile_SaveFailure verifies that when other
1194+ // profiles remain after the delete, a cfg.SaveTo failure (target file made
1195+ // read-only) is surfaced as ErrRemoveProfile instead of being silently
1196+ // swallowed.
1197+ func TestAWSFileManager_RemoveConfigProfile_SaveFailure (t * testing.T ) {
1198+ skipIfCannotDenyDirWrite (t )
1199+
1200+ tmp := t .TempDir ()
1201+ m := & AWSFileManager {baseDir : tmp }
1202+
1203+ require .NoError (t , m .WriteConfig ("prov" , "keep" , "us-east-1" , "json" ))
1204+ require .NoError (t , m .WriteConfig ("prov" , "dev" , "us-west-2" , "yaml" ))
1205+
1206+ configPath := m .GetConfigPath ("prov" )
1207+ require .NoError (t , os .Chmod (configPath , 0o444 )) // Read-only: blocks the rewrite triggered by deleting "dev".
1208+ t .Cleanup (func () { _ = os .Chmod (configPath , PermissionRW ) })
1209+
1210+ err := m .RemoveConfigProfile (context .Background (), "prov" , "dev" )
1211+ require .Error (t , err )
1212+ assert .ErrorIs (t , err , ErrRemoveProfile )
1213+ }
1214+
1215+ // TestAWSFileManager_RemoveCredentialsProfile_SaveFailure mirrors the config
1216+ // case for RemoveCredentialsProfile's cfg.SaveTo failure branch.
1217+ func TestAWSFileManager_RemoveCredentialsProfile_SaveFailure (t * testing.T ) {
1218+ skipIfCannotDenyDirWrite (t )
1219+
1220+ tmp := t .TempDir ()
1221+ m := & AWSFileManager {baseDir : tmp }
1222+
1223+ require .NoError (t , m .WriteCredentials ("prov" , "keep" , & types.AWSCredentials {AccessKeyID : "AKIA1" , SecretAccessKey : "secret1" }))
1224+ require .NoError (t , m .WriteCredentials ("prov" , "dev" , & types.AWSCredentials {AccessKeyID : "AKIA2" , SecretAccessKey : "secret2" }))
1225+
1226+ credsPath := m .GetCredentialsPath ("prov" )
1227+ require .NoError (t , os .Chmod (credsPath , 0o444 ))
1228+ t .Cleanup (func () { _ = os .Chmod (credsPath , PermissionRW ) })
1229+
1230+ err := m .RemoveCredentialsProfile (context .Background (), "prov" , "dev" )
1231+ require .Error (t , err )
1232+ assert .ErrorIs (t , err , ErrRemoveProfile )
1233+ }
1234+
1235+ // TestAWSFileManager_RemoveConfigProfile_RemoveFailure verifies that when no
1236+ // profiles remain and the file must be deleted, an os.Remove failure (the
1237+ // containing directory made read-only, which blocks unlink on POSIX even
1238+ // though the file itself is readable) is surfaced as ErrRemoveProfile.
1239+ func TestAWSFileManager_RemoveConfigProfile_RemoveFailure (t * testing.T ) {
1240+ skipIfCannotDenyDirWrite (t )
1241+
1242+ tmp := t .TempDir ()
1243+ m := & AWSFileManager {baseDir : tmp }
1244+
1245+ require .NoError (t , m .WriteConfig ("prov" , "dev" , "us-east-1" , "json" ))
1246+
1247+ configDir := filepath .Dir (m .GetConfigPath ("prov" ))
1248+ require .NoError (t , os .Chmod (configDir , 0o555 )) // Read-only: blocks unlinking the now-empty config file.
1249+ t .Cleanup (func () { _ = os .Chmod (configDir , PermissionRWX ) })
1250+
1251+ err := m .RemoveConfigProfile (context .Background (), "prov" , "dev" )
1252+ require .Error (t , err )
1253+ assert .ErrorIs (t , err , ErrRemoveProfile )
1254+ }
1255+
1256+ // TestAWSFileManager_RemoveCredentialsProfile_RemoveFailure mirrors the
1257+ // config case for RemoveCredentialsProfile's os.Remove failure branch.
1258+ func TestAWSFileManager_RemoveCredentialsProfile_RemoveFailure (t * testing.T ) {
1259+ skipIfCannotDenyDirWrite (t )
1260+
1261+ tmp := t .TempDir ()
1262+ m := & AWSFileManager {baseDir : tmp }
1263+
1264+ require .NoError (t , m .WriteCredentials ("prov" , "dev" , & types.AWSCredentials {AccessKeyID : "AKIA123" , SecretAccessKey : "secret" }))
1265+
1266+ credsDir := filepath .Dir (m .GetCredentialsPath ("prov" ))
1267+ require .NoError (t , os .Chmod (credsDir , 0o555 ))
1268+ t .Cleanup (func () { _ = os .Chmod (credsDir , PermissionRWX ) })
1269+
1270+ err := m .RemoveCredentialsProfile (context .Background (), "prov" , "dev" )
1271+ require .Error (t , err )
1272+ assert .ErrorIs (t , err , ErrRemoveProfile )
1273+ }
0 commit comments