github.com/olivere/camlistore@v0.0.0-20140121221811-1b7ac2da0199/server/tester/bs-test.pl (about) 1 #!/usr/bin/perl 2 # 3 # Test script to run against a Camli blobserver to test its compliance 4 # with the spec. 5 6 use strict; 7 use Getopt::Long; 8 use LWP; 9 use Test::More; 10 11 my $user; 12 my $password; 13 my $implopt; 14 GetOptions("user" => \$user, 15 "password" => \$password, 16 "impl=s" => \$implopt, 17 ) or usage(); 18 19 my $impl; 20 my %args = (user => $user, password => $password); 21 if ($implopt eq "go") { 22 $impl = Impl::Go->new(%args); 23 } elsif ($implopt eq "appengine") { 24 $impl = Impl::AppEngine->new(%args); 25 } else { 26 die "The --impl flag must be 'go' or 'appengine'.\n"; 27 } 28 29 ok($impl->start, "Server started"); 30 31 $impl->verify_no_blobs; # also tests some of enumerate 32 $impl->test_stat_and_upload; 33 $impl->test_upload_corrupt_blob; # blobref digest doesn't match 34 35 # TODO: test multiple uploads in a batch 36 # TODO: test uploads in serial (using each response's next uploadUrl) 37 # TODO: test enumerate boundaries 38 # TODO: interrupt a POST upload in the middle; verify no straggler on 39 # disk in subsequent GET 40 # .... 41 # test auth works on bogus password? (auth still undefined) 42 # TODO: test stat with both GET and POST (currently just POST) 43 44 done_testing(); 45 46 sub usage { 47 die "Usage: bs-test.pl [--user= --password=] --impl={go,appengine}\n"; 48 } 49 50 package Impl; 51 use HTTP::Request::Common; 52 use LWP::UserAgent; 53 use JSON::Any; 54 use Test::More; 55 use Digest::SHA1 qw(sha1_hex); 56 use URI::URL (); 57 use Data::Dumper; 58 59 sub new { 60 my ($class, %args) = @_; 61 return bless \%args, $class; 62 } 63 64 sub post { 65 my ($self, $path, $form) = @_; 66 $path ||= ""; 67 $form ||= {}; 68 return POST($self->path($path), 69 "Authorization" => "Basic dGVzdDp0ZXN0", # test:test 70 Content => $form); 71 } 72 73 sub upload_request { 74 my ($self, $upload_url, $blobref_to_blob_map) = @_; 75 my @content; 76 my $n = 0; 77 foreach my $key (sort keys %$blobref_to_blob_map) { 78 $n++; 79 # TODO: the App Engine client refused to work unless the Content-Type 80 # is set. This should be clarified in the docs (MUST?) and update the 81 # test suite and Go server accordingly (to fail if not present). 82 push @content, $key => [ 83 undef, "filename$n", 84 "Content-Type" => "application/octet-stream", 85 Content => $blobref_to_blob_map->{$key}, 86 ]; 87 } 88 89 return POST($upload_url, 90 "Content_Type" => 'form-data', 91 "Authorization" => "Basic dGVzdDp0ZXN0", # test:test 92 Content => \@content); 93 } 94 95 sub get { 96 my ($self, $path, $form) = @_; 97 $path ||= ""; 98 $form ||= {}; 99 return GET($self->path($path), 100 "Authorization" => "Basic dGVzdDp0ZXN0", # test:test 101 %$form); 102 } 103 104 sub head { 105 my ($self, $path, $form) = @_; 106 $path ||= ""; 107 $form ||= {}; 108 return HEAD($self->path($path), 109 "Authorization" => "Basic dGVzdDp0ZXN0", # test:test 110 %$form); 111 } 112 113 sub ua { 114 my $self = shift; 115 return ($self->{_ua} ||= LWP::UserAgent->new(agent => "camli/blobserver-tester")); 116 } 117 118 sub root { 119 my $self= shift; 120 return $self->{root} or die "No 'root' for $self"; 121 } 122 123 sub path { 124 my $self = shift; 125 my $path = shift || ""; 126 return $self->root . $path; 127 } 128 129 sub get_json { 130 my ($self, $req, $msg, $opts) = @_; 131 $opts ||= {}; 132 133 my $res = $self->ua->request($req); 134 ok(defined($res), "got response for HTTP request '$msg'"); 135 136 if ($res->code =~ m!^30[123]$! && $opts->{follow_redirect}) { 137 my $location = $res->header("Location"); 138 if ($res->code == "303") { 139 $req->method("GET"); 140 } 141 my $new_uri = URI::URL->new($location, $req->uri)->abs; 142 diag("Old URI was " . $req->uri); 143 diag("New is " . $new_uri); 144 diag("Redirecting HTTP request '$msg' to $location ($new_uri)"); 145 $req->uri($new_uri); 146 $res = $self->ua->request($req); 147 ok(defined($res), "got redirected response for HTTP request '$msg'"); 148 } 149 150 ok($res->is_success, "successful response for HTTP request '$msg'") 151 or diag("Status was: " . $res->status_line); 152 my $json = JSON::Any->jsonToObj($res->content); 153 is("HASH", ref($json), "JSON parsed for HTTP request '$msg'") 154 or BAIL_OUT("expected JSON response"); 155 return $json; 156 } 157 158 sub get_upload_json { 159 my ($self, $req) = @_; 160 return $self->get_json($req, "upload", { follow_redirect => 1 }) 161 } 162 163 sub verify_no_blobs { 164 my $self = shift; 165 my $req = $self->get("/camli/enumerate-blobs", { 166 "after" => "", 167 "limit" => 10, 168 }); 169 my $json = $self->get_json($req, "enumerate empty blobs"); 170 ok(defined($json->{'blobs'}), "enumerate has a 'blobs' key"); 171 is("ARRAY", ref($json->{'blobs'}), "enumerate's blobs key is an array"); 172 is(0, scalar @{$json->{'blobs'}}, "no blobs on server"); 173 } 174 175 sub test_stat_and_upload { 176 my $self = shift; 177 my ($req, $res); 178 179 my $blob = "This is a line.\r\nWith mixed newlines\rFoo\nAnd binary\0data.\0\n\r."; 180 my $blobref = "sha1-" . sha1_hex($blob); 181 182 # Bogus method. 183 $req = $self->head("/camli/stat", { 184 "camliversion" => 1, 185 "blob1" => $blobref, 186 }); 187 $res = $self->ua->request($req); 188 ok(!$res->is_success, "returns failure for HEAD on /camli/stat"); 189 190 # Correct method, but missing camliVersion. 191 $req = $self->post("/camli/stat", { 192 "blob1" => $blobref, 193 }); 194 $res = $self->ua->request($req); 195 ok(!$res->is_success, "returns failure for missing camliVersion param on stat"); 196 197 # Valid pre-upload 198 $req = $self->post("/camli/stat", { 199 "camliversion" => 1, 200 "blob1" => $blobref, 201 }); 202 my $jres = $self->get_json($req, "valid stat"); 203 diag("stat response: " . Dumper($jres)); 204 ok($jres, "valid stat JSON response"); 205 for my $f (qw(stat maxUploadSize uploadUrl uploadUrlExpirationSeconds)) { 206 ok(defined($jres->{$f}), "required field '$f' present"); 207 } 208 is(scalar(keys %$jres), 4, "Exactly 4 JSON keys returned"); 209 my $statList = $jres->{stat}; 210 is(ref($statList), "ARRAY", "stat is an array"); 211 is(scalar(@$statList), 0, "server doesn't have this blob yet."); 212 like($jres->{uploadUrlExpirationSeconds}, qr/^\d+$/, "uploadUrlExpirationSeconds is numeric"); 213 my $upload_url = URI::URL->new($jres->{uploadUrl}, $self->root)->abs; 214 ok($upload_url, "valid uploadUrl"); 215 # TODO: test & clarify in spec: are relative URLs allowed in uploadUrl? 216 # App Engine seems to do it already, and makes it easier, so probably 217 # best to clarify that they're relative. 218 219 # Do the actual upload 220 my $upreq = $self->upload_request($upload_url, { 221 $blobref => $blob, 222 }); 223 diag("upload request: " . $upreq->as_string); 224 my $upres = $self->get_upload_json($upreq); 225 ok($upres, "Upload was success"); 226 print STDERR "# upload response: ", Dumper($upres); 227 228 for my $f (qw(uploadUrlExpirationSeconds uploadUrl maxUploadSize received)) { 229 ok(defined($upres->{$f}), "required upload response field '$f' present"); 230 } 231 is(scalar(keys %$upres), 4, "Exactly 4 JSON keys returned"); 232 233 like($upres->{uploadUrlExpirationSeconds}, qr/^\d+$/, "uploadUrlExpirationSeconds is numeric"); 234 is(ref($upres->{received}), "ARRAY", "'received' is an array") 235 or BAIL_OUT(); 236 my $got = $upres->{received}; 237 is(scalar(@$got), 1, "got one file"); 238 is($got->[0]{blobRef}, $blobref, "received[0] 'blobRef' matches"); 239 is($got->[0]{size}, length($blob), "received[0] 'size' matches"); 240 241 # TODO: do a get request, verify that we get it back. 242 } 243 244 sub test_upload_corrupt_blob { 245 my $self = shift; 246 my ($req, $res); 247 248 my $blob = "A blob, pre-corruption."; 249 my $blobref = "sha1-" . sha1_hex($blob); 250 $blob .= "OIEWUROIEWURLKJDSLKj CORRUPT"; 251 252 $req = $self->post("/camli/stat", { 253 "camliversion" => 1, 254 "blob1" => $blobref, 255 }); 256 my $jres = $self->get_json($req, "valid stat"); 257 my $upload_url = URI::URL->new($jres->{uploadUrl}, $self->root)->abs; 258 # TODO: test & clarify in spec: are relative URLs allowed in uploadUrl? 259 # App Engine seems to do it already, and makes it easier, so probably 260 # best to clarify that they're relative. 261 262 # Do the actual upload 263 my $upreq = $self->upload_request($upload_url, { 264 $blobref => $blob, 265 }); 266 diag("corrupt upload request: " . $upreq->as_string); 267 my $upres = $self->get_upload_json($upreq); 268 my $got = $upres->{received}; 269 is(ref($got), "ARRAY", "corrupt upload returned a 'received' array"); 270 is(scalar(@$got), 0, "didn't get any files (it was corrupt)"); 271 } 272 273 package Impl::Go; 274 use base 'Impl'; 275 use FindBin; 276 use LWP::UserAgent; 277 use HTTP::Request; 278 use Fcntl; 279 use File::Temp (); 280 281 sub start { 282 my $self = shift; 283 284 $self->{_tmpdir_obj} = File::Temp->newdir(); 285 my $tmpdir = $self->{_tmpdir_obj}->dirname; 286 287 die "Failed to create temporary directory." unless -d $tmpdir; 288 289 system("$FindBin::Bin/../../build.pl", "server/go/blobserver") 290 and die "Failed to build Go blobserver."; 291 292 my $bindir = "$FindBin::Bin/../go/blobserver/"; 293 my $binary = "$bindir/blobserver"; 294 295 chdir($bindir) or die "filed to chdir to $bindir: $!"; 296 system("make") and die "failed to run make in $bindir"; 297 298 my ($port_rd, $port_wr, $exit_rd, $exit_wr); 299 my $flags; 300 pipe $port_rd, $port_wr; 301 pipe $exit_rd, $exit_wr; 302 303 $flags = fcntl($port_wr, F_GETFD, 0); 304 fcntl($port_wr, F_SETFD, $flags & ~FD_CLOEXEC); 305 $flags = fcntl($exit_rd, F_GETFD, 0); 306 fcntl($exit_rd, F_SETFD, $flags & ~FD_CLOEXEC); 307 308 $ENV{TESTING_PORT_WRITE_FD} = fileno($port_wr); 309 $ENV{TESTING_CONTROL_READ_FD} = fileno($exit_rd); 310 $ENV{CAMLI_PASSWORD} = "test"; 311 312 die "Binary $binary doesn't exist\n" unless -x $binary; 313 314 my $pid = fork; 315 die "Failed to fork" unless defined($pid); 316 if ($pid == 0) { 317 # child 318 my @args = ($binary, "-listen=:0", "-root=$tmpdir"); 319 print STDERR "# Running: [@args]\n"; 320 exec @args; 321 die "failed to exec: $!\n"; 322 } 323 close($exit_rd); # child owns this side 324 close($port_wr); # child owns this side 325 326 print "Waiting for Go server to start...\n"; 327 my $line = <$port_rd>; 328 close($port_rd); 329 330 # Parse the port line out 331 chomp $line; 332 # print "Got port line: $line\n"; 333 die "Failed to start, no port info." unless $line =~ /:(\d+)$/; 334 $self->{port} = $1; 335 $self->{root} = "http://localhost:$self->{port}"; 336 print STDERR "# Running on $self->{root} ...\n"; 337 338 # Keep a reference to this to write "EXIT\n" to in order 339 # to cleanly shutdown the child camlistored process. 340 # If we close it, the child also dies, though. 341 $self->{_exit_wr} = $exit_wr; 342 return 1; 343 } 344 345 sub DESTROY { 346 my $self = shift; 347 syswrite($self->{_exit_wr}, "EXIT\n"); 348 } 349 350 package Impl::AppEngine; 351 use base 'Impl'; 352 use IO::Socket::INET; 353 use Time::HiRes (); 354 355 sub start { 356 my $self = shift; 357 358 my $dev_appserver = `which dev_appserver.py`; 359 chomp $dev_appserver; 360 unless ($dev_appserver && -x $dev_appserver) { 361 $dev_appserver = "$ENV{HOME}/sdk/google_appengine/dev_appserver.py"; 362 unless (-x $dev_appserver) { 363 die "No dev_appserver.py in \$PATH nor in \$HOME/sdk/google_appengine/dev_appserver.py\n"; 364 } 365 } 366 367 $self->{_tempdir_blobstore_obj} = File::Temp->newdir(); 368 $self->{_tempdir_datastore_obj} = File::Temp->newdir(); 369 my $datapath = $self->{_tempdir_blobstore_obj}->dirname . "/datastore-file"; 370 my $blobdir = $self->{_tempdir_datastore_obj}->dirname; 371 372 my $port; 373 while (1) { 374 $port = int(rand(30000) + 1024); 375 my $sock = IO::Socket::INET->new(Listen => 5, 376 LocalAddr => '127.0.0.1', 377 LocalPort => $port, 378 ReuseAddr => 1, 379 Proto => 'tcp'); 380 if ($sock) { 381 last; 382 } 383 } 384 $self->{port} = $port; 385 $self->{root} = "http://localhost:$self->{port}"; 386 387 my $pid = fork; 388 die "Failed to fork" unless defined($pid); 389 if ($pid == 0) { 390 my $appdir = "$FindBin::Bin/../appengine/blobserver"; 391 392 # child 393 my @args = ($dev_appserver, 394 "--clear_datastore", # kinda redundant as we made a temp dir 395 "--datastore_path=$datapath", 396 "--blobstore_path=$blobdir", 397 "--port=$port", 398 $appdir); 399 print STDERR "# Running: [@args]\n"; 400 exec @args; 401 die "failed to exec: $!\n"; 402 } 403 $self->{pid} = $pid; 404 405 my $last_print = 0; 406 for (1..15) { 407 my $now = time(); 408 if ($now != $last_print) { 409 print STDERR "# Waiting for appengine app to start...\n"; 410 $last_print = $now; 411 } 412 my $res = $self->ua->request($self->get("/")); 413 if ($res && $res->is_success) { 414 print STDERR "# Up."; 415 last; 416 } 417 Time::HiRes::sleep(0.1); 418 } 419 return 1; 420 } 421 422 sub DESTROY { 423 my $self = shift; 424 kill 3, $self->{pid} if $self->{pid}; 425 } 426 427 1; 428 429 430