@@ -905,6 +905,256 @@ async def test_file_save_artifact_rejects_absolute_path_within_scope(tmp_path):
905905 )
906906
907907
908+ @pytest .mark .asyncio
909+ @pytest .mark .parametrize (
910+ "service_type" ,
911+ [
912+ ArtifactServiceType .IN_MEMORY ,
913+ ArtifactServiceType .GCS ,
914+ ],
915+ )
916+ async def test_artifact_reference_allows_same_session_scope (
917+ service_type , artifact_service_factory
918+ ):
919+ """ArtifactService allows references inside the same session scope."""
920+ artifact_service = artifact_service_factory (service_type )
921+
922+ await artifact_service .save_artifact (
923+ app_name = "app0" ,
924+ user_id = "user0" ,
925+ session_id = "sess0" ,
926+ filename = "source.txt" ,
927+ artifact = types .Part (text = "hello" ),
928+ )
929+
930+ ref = types .Part (
931+ file_data = types .FileData (
932+ file_uri = (
933+ "artifact://apps/app0/users/user0/sessions/sess0/"
934+ "artifacts/source.txt/versions/0"
935+ ),
936+ mime_type = "text/plain" ,
937+ )
938+ )
939+ await artifact_service .save_artifact (
940+ app_name = "app0" ,
941+ user_id = "user0" ,
942+ session_id = "sess0" ,
943+ filename = "ref.txt" ,
944+ artifact = ref ,
945+ )
946+
947+ loaded = await artifact_service .load_artifact (
948+ app_name = "app0" ,
949+ user_id = "user0" ,
950+ session_id = "sess0" ,
951+ filename = "ref.txt" ,
952+ )
953+ assert loaded == types .Part (text = "hello" )
954+
955+
956+ @pytest .mark .asyncio
957+ @pytest .mark .parametrize (
958+ "service_type" ,
959+ [
960+ ArtifactServiceType .IN_MEMORY ,
961+ ArtifactServiceType .GCS ,
962+ ],
963+ )
964+ async def test_artifact_reference_allows_same_user_user_scope (
965+ service_type , artifact_service_factory
966+ ):
967+ """ArtifactService allows references to user-scoped files from same user."""
968+ artifact_service = artifact_service_factory (service_type )
969+
970+ await artifact_service .save_artifact (
971+ app_name = "app0" ,
972+ user_id = "user0" ,
973+ session_id = "sess0" ,
974+ filename = "user:profile.txt" ,
975+ artifact = types .Part (text = "profile" ),
976+ )
977+
978+ ref = types .Part (
979+ file_data = types .FileData (
980+ file_uri = (
981+ "artifact://apps/app0/users/user0/artifacts/"
982+ "user:profile.txt/versions/0"
983+ ),
984+ mime_type = "text/plain" ,
985+ )
986+ )
987+ await artifact_service .save_artifact (
988+ app_name = "app0" ,
989+ user_id = "user0" ,
990+ session_id = "sess1" ,
991+ filename = "ref.txt" ,
992+ artifact = ref ,
993+ )
994+
995+ loaded = await artifact_service .load_artifact (
996+ app_name = "app0" ,
997+ user_id = "user0" ,
998+ session_id = "sess1" ,
999+ filename = "ref.txt" ,
1000+ )
1001+ assert loaded == types .Part (text = "profile" )
1002+
1003+
1004+ @pytest .mark .asyncio
1005+ @pytest .mark .parametrize (
1006+ "service_type" ,
1007+ [
1008+ ArtifactServiceType .IN_MEMORY ,
1009+ ArtifactServiceType .GCS ,
1010+ ],
1011+ )
1012+ async def test_artifact_reference_rejects_cross_user_on_save (
1013+ service_type , artifact_service_factory
1014+ ):
1015+ """ArtifactService rejects references to different users on save."""
1016+ artifact_service = artifact_service_factory (service_type )
1017+
1018+ await artifact_service .save_artifact (
1019+ app_name = "app0" ,
1020+ user_id = "victim" ,
1021+ session_id = "victim-sess" ,
1022+ filename = "user:secret.txt" ,
1023+ artifact = types .Part (text = "secret" ),
1024+ )
1025+
1026+ ref = types .Part (
1027+ file_data = types .FileData (
1028+ file_uri = (
1029+ "artifact://apps/app0/users/victim/artifacts/"
1030+ "user:secret.txt/versions/0"
1031+ ),
1032+ mime_type = "text/plain" ,
1033+ )
1034+ )
1035+ with pytest .raises (InputValidationError , match = "same app and user scope" ):
1036+ await artifact_service .save_artifact (
1037+ app_name = "app0" ,
1038+ user_id = "attacker" ,
1039+ session_id = "attacker-sess" ,
1040+ filename = "ref.txt" ,
1041+ artifact = ref ,
1042+ )
1043+
1044+
1045+ @pytest .mark .asyncio
1046+ @pytest .mark .parametrize (
1047+ "service_type" ,
1048+ [
1049+ ArtifactServiceType .IN_MEMORY ,
1050+ ArtifactServiceType .GCS ,
1051+ ],
1052+ )
1053+ async def test_artifact_reference_rejects_cross_app_on_save (
1054+ service_type , artifact_service_factory
1055+ ):
1056+ """ArtifactService rejects references to different apps on save."""
1057+ artifact_service = artifact_service_factory (service_type )
1058+
1059+ await artifact_service .save_artifact (
1060+ app_name = "victim-app" ,
1061+ user_id = "user0" ,
1062+ session_id = "sess0" ,
1063+ filename = "user:secret.txt" ,
1064+ artifact = types .Part (text = "secret" ),
1065+ )
1066+
1067+ ref = types .Part (
1068+ file_data = types .FileData (
1069+ file_uri = (
1070+ "artifact://apps/victim-app/users/user0/artifacts/"
1071+ "user:secret.txt/versions/0"
1072+ ),
1073+ mime_type = "text/plain" ,
1074+ )
1075+ )
1076+ with pytest .raises (InputValidationError , match = "same app and user scope" ):
1077+ await artifact_service .save_artifact (
1078+ app_name = "attacker-app" ,
1079+ user_id = "user0" ,
1080+ session_id = "sess0" ,
1081+ filename = "ref.txt" ,
1082+ artifact = ref ,
1083+ )
1084+
1085+
1086+ @pytest .mark .asyncio
1087+ @pytest .mark .parametrize (
1088+ "service_type" ,
1089+ [
1090+ ArtifactServiceType .IN_MEMORY ,
1091+ ArtifactServiceType .GCS ,
1092+ ],
1093+ )
1094+ async def test_artifact_reference_rejects_cross_session_on_load (
1095+ service_type , artifact_service_factory
1096+ ):
1097+ """ArtifactService rejects modified references to different sessions on load."""
1098+ artifact_service = artifact_service_factory (service_type )
1099+
1100+ await artifact_service .save_artifact (
1101+ app_name = "app0" ,
1102+ user_id = "user0" ,
1103+ session_id = "sess0" ,
1104+ filename = "source.txt" ,
1105+ artifact = types .Part (text = "source" ),
1106+ )
1107+ await artifact_service .save_artifact (
1108+ app_name = "app0" ,
1109+ user_id = "user0" ,
1110+ session_id = "sess1" ,
1111+ filename = "source.txt" ,
1112+ artifact = types .Part (text = "other-session" ),
1113+ )
1114+
1115+ ref = types .Part (
1116+ file_data = types .FileData (
1117+ file_uri = (
1118+ "artifact://apps/app0/users/user0/sessions/sess0/"
1119+ "artifacts/source.txt/versions/0"
1120+ ),
1121+ mime_type = "text/plain" ,
1122+ )
1123+ )
1124+ await artifact_service .save_artifact (
1125+ app_name = "app0" ,
1126+ user_id = "user0" ,
1127+ session_id = "sess0" ,
1128+ filename = "ref.txt" ,
1129+ artifact = ref ,
1130+ )
1131+
1132+ new_uri = (
1133+ "artifact://apps/app0/users/user0/sessions/sess1/"
1134+ "artifacts/source.txt/versions/0"
1135+ )
1136+ # Manually modify the stored reference URI to point to a different session.
1137+ if service_type == ArtifactServiceType .GCS :
1138+ blob_name = artifact_service ._get_blob_name (
1139+ "app0" , "user0" , "ref.txt" , 0 , "sess0"
1140+ )
1141+ blob = artifact_service .bucket .get_blob (blob_name )
1142+ blob .metadata ["adkFileUri" ] = new_uri
1143+ elif service_type == ArtifactServiceType .IN_MEMORY :
1144+ ref_path = artifact_service ._artifact_path (
1145+ "app0" , "user0" , "ref.txt" , "sess0"
1146+ )
1147+ artifact_service .artifacts [ref_path ][0 ].data .file_data .file_uri = new_uri
1148+
1149+ with pytest .raises (InputValidationError , match = "same session scope" ):
1150+ await artifact_service .load_artifact (
1151+ app_name = "app0" ,
1152+ user_id = "user0" ,
1153+ session_id = "sess0" ,
1154+ filename = "ref.txt" ,
1155+ )
1156+
1157+
9081158class TestEnsurePart :
9091159 """Tests for the ensure_part normalization helper."""
9101160
0 commit comments