Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions .sdkharness/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
assertions in `tests/`; the harness builds this checkout, starts a fresh local B2 simulator,
and retains and reports the evidence.

One scenario so far: `health/golden-path`, the customer golden path. `tests/run-health` runs
Scenarios so far: `health/golden-path` and eight `conformance` scenarios (below). `tests/run-health` runs
`tests/health/GoldenPath.java` as a single-file Java program that authorizes, uploads a small
file, downloads it and compares the bytes, lists it, deletes it and confirms it is gone, then
prints one result line:
Expand All @@ -14,20 +14,47 @@ prints one result line:
SDKHARNESS_RESULT health golden-path PASS -
```

## Conformance

`tests/run-conformance` runs one capability per call, named by `SDKHARNESS_SCENARIO`; every row in
`tests.tsv` points at it. Each scenario is a Java program in `tests/conformance/` that talks to the
simulator through the SDK's public API and asserts what B2's documentation says happens (each file
cites its pages). They share `tests/conformance/Support.java`; a JDK 11 single-file launch cannot
import a second source file, so the wrapper compiles `Support.java` and the check with `javac`
against the harness-built jars, then runs the check class. Each check creates its own
`sdkharness-conf-*` buckets and deletes everything it made, on failure too.

| Scenario | What it asserts |
|---|---|
| `files.upload` | the response (name, size, sha1, type, fileInfo), listing, byte- and sha1-identical download, an empty file, an unusual file name |
| `files.download_content` | download by name: bytes and headers, a Range, a missing name is 404 |
| `files.download_by_id` | two versions of one name each download as themselves; by name is the latest |
| `files.list` | prefix, paging (page size 2), start name, delimiter folders, versions newest first |
| `files.delete_version` | one version deleted: gone from the listing and 404 by id, the other survives |
| `files.hide` | hide marker is a new version; not in the default listing, visible in the version listing; 404 by name; the original still downloads by id |
| `bucket.crud` | create, duplicate name refused, list, update type and info (revision grows), delete refused while non-empty, delete |
| `files.metadata` | by id (`b2_get_file_info`) and by name (an HTTP HEAD of the download URL: the cell is `partial`); the check records the SDK's own requests and asserts that boundary |

A failing scenario prints one `SDKHARNESS_RESULT ... FAIL` line with the step and the reason.

## What the check can and cannot reach

- **Only the loopback simulator.** `run-health` refuses any `SDKHARNESS_SIMULATOR_URL` that
is not `http://127.0.0.1:<port>`. The SDK's HTTP support for test servers
(`HttpClientFactoryImpl.Builder.setSupportInsecureHttp`) is enabled for that reason only.
- **Only the simulator's fixed test credential** (`test-key-id` / `test-key`, in the source).
Every ambient `B2_*` variable except `B2_BUCKET_NAME` is dropped first.
Every ambient `B2_*` variable except `B2_BUCKET_NAME` is dropped first (conformance drops all of
them and makes its own buckets).
- **No build.** The harness builds the `core` and `httpclient` jars from this checkout and
passes them, with their runtime dependencies, as `SDKHARNESS_JAVA_CLASSPATH`. The build
files here are not changed. A missing `java` on `PATH` reports `SKIP`, which the harness
counts as a failure.

## Run it by hand

For conformance, set `SDKHARNESS_TEST_LEVEL=conformance`, `SDKHARNESS_SCENARIO=<capability>` and run
`.sdkharness/tests/run-conformance` (needs `javac`, and no `B2_BUCKET_NAME`).

Requires JDK 11 or later and a simulator (`backblaze-labs/b2-simulator`) with a bucket named
`sdkharness-healthcheck`:

Expand Down
8 changes: 8 additions & 0 deletions .sdkharness/tests.tsv
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
test_level scenario target executable
health golden-path simulator ./.sdkharness/tests/run-health
conformance files.upload simulator ./.sdkharness/tests/run-conformance
conformance files.download_content simulator ./.sdkharness/tests/run-conformance
conformance files.download_by_id simulator ./.sdkharness/tests/run-conformance
conformance files.list simulator ./.sdkharness/tests/run-conformance
conformance files.delete_version simulator ./.sdkharness/tests/run-conformance
conformance files.hide simulator ./.sdkharness/tests/run-conformance
conformance bucket.crud simulator ./.sdkharness/tests/run-conformance
conformance files.metadata simulator ./.sdkharness/tests/run-conformance
89 changes: 89 additions & 0 deletions .sdkharness/tests/conformance/BucketCrud.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Copyright 2026, Backblaze Inc. All Rights Reserved.
// License https://www.backblaze.com/using_b2_code.html

import com.backblaze.b2.client.exceptions.B2Exception;
import com.backblaze.b2.client.structures.B2Bucket;
import com.backblaze.b2.client.structures.B2CreateBucketRequest;
import com.backblaze.b2.client.structures.B2FileVersion;
import com.backblaze.b2.client.structures.B2UpdateBucketRequest;

import java.util.Collections;
import java.util.Map;

/**
* bucket.crud. Run by ../run-conformance (see ../../README.md).
*
* <p>Create, list, update and delete a bucket: b2_create_bucket returns the bucket with its type and
* info (and refuses a name that is taken, duplicate_bucket_name); b2_list_buckets finds it; b2_update_bucket
* changes the type and info and bumps the revision; b2_delete_bucket "can only delete buckets that
* contain no version of any files" (cannot_delete_non_empty_bucket) and afterwards the bucket is gone.
* https://www.backblaze.com/apidocs/b2-create-bucket
* https://www.backblaze.com/apidocs/b2-list-buckets
* https://www.backblaze.com/apidocs/b2-update-bucket
* https://www.backblaze.com/apidocs/b2-delete-bucket
*/
public class BucketCrud {
public static void main(String[] args) {
Support.run("bucket.crud", () -> {
Support.step("create");
final String name = Support.newBucketName();
final Map<String, String> info = Collections.singletonMap("sdkharness-bucket", "created");
final B2Bucket created = Support.client.createBucket(B2CreateBucketRequest.builder(name, "allPrivate").setBucketInfo(info).build());
Support.track(created);
Support.eq("created bucketName", name, created.getBucketName());
Support.eq("created bucketType", "allPrivate", created.getBucketType());
Support.eq("created bucketInfo", "created", created.getBucketInfo().get("sdkharness-bucket"));
Support.check(created.getBucketId() != null && !created.getBucketId().isEmpty(), "bucketId is empty");

Support.step("duplicate name");
final B2Exception duplicate = Support.expectError("create with a name already in use",
() -> Support.client.createBucket(name, "allPrivate"));
Support.eq("duplicate create status", 400, duplicate.getStatus());
Support.eq("duplicate create code", "duplicate_bucket_name", duplicate.getCode());

Support.step("list");
B2Bucket listed = null;
for (B2Bucket b : Support.client.buckets()) {
if (created.getBucketId().equals(b.getBucketId())) {
listed = b;
}
}
Support.check(listed != null, "the new bucket is not in the bucket list");
Support.eq("listed bucketName", name, listed.getBucketName());
Support.eq("listed bucketType", "allPrivate", listed.getBucketType());
final B2Bucket byName = Support.client.getBucketOrNullByName(name);
Support.check(byName != null && created.getBucketId().equals(byName.getBucketId()), "the bucket is not found by name");

Support.step("update");
final B2Bucket updated = Support.client.updateBucket(B2UpdateBucketRequest.builder(created)
.setBucketType("allPublic")
.setBucketInfo(Collections.singletonMap("sdkharness-bucket", "updated"))
.build());
Support.eq("updated bucketType", "allPublic", updated.getBucketType());
Support.eq("updated bucketInfo", "updated", updated.getBucketInfo().get("sdkharness-bucket"));
Support.check(updated.getRevision() > created.getRevision(),
"the revision did not increase: " + created.getRevision() + " then " + updated.getRevision());
final B2Bucket reread = Support.client.getBucketOrNullByName(name);
Support.check(reread != null, "the bucket disappeared after the update");
Support.eq("re-read bucketType", "allPublic", reread.getBucketType());
Support.eq("re-read bucketInfo", "updated", reread.getBucketInfo().get("sdkharness-bucket"));

Support.step("delete refuses a bucket with files");
final B2FileVersion file = Support.upload(updated, "st/bucket/file.txt", Support.utf8("in the bucket"), "text/plain", null);
final B2Exception notEmpty = Support.expectError("delete of a bucket that holds a file",
() -> Support.client.deleteBucket(updated.getBucketId()));
Support.eq("non-empty delete status", 400, notEmpty.getStatus());
Support.eq("non-empty delete code", "cannot_delete_non_empty_bucket", notEmpty.getCode());
Support.check(Support.client.getBucketOrNullByName(name) != null, "the bucket was deleted although it held a file");

Support.step("delete");
Support.client.deleteFileVersion(file);
Support.client.deleteBucket(updated.getBucketId());
Support.untrack(updated);
Support.check(Support.client.getBucketOrNullByName(name) == null, "the bucket is still found by name after delete");
for (B2Bucket b : Support.client.buckets()) {
Support.check(!updated.getBucketId().equals(b.getBucketId()), "the deleted bucket is still in the bucket list");
}
});
}
}
61 changes: 61 additions & 0 deletions .sdkharness/tests/conformance/FilesDeleteVersion.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright 2026, Backblaze Inc. All Rights Reserved.
// License https://www.backblaze.com/using_b2_code.html

import com.backblaze.b2.client.exceptions.B2Exception;
import com.backblaze.b2.client.structures.B2Bucket;
import com.backblaze.b2.client.structures.B2FileVersion;
import com.backblaze.b2.client.structures.B2ListFileNamesRequest;
import com.backblaze.b2.client.structures.B2ListFileVersionsRequest;

import java.util.Arrays;
import java.util.List;

/**
* files.delete_version. Run by ../run-conformance (see ../../README.md).
*
* <p>b2_delete_file_version "deletes one version of a file"; the other versions of the name stay.
* Once deleted, the version is gone from the version listing and a download of its fileId is a 404
* not_found, while the surviving version still downloads and is what the name listing shows.
* https://www.backblaze.com/apidocs/b2-delete-file-version
*/
public class FilesDeleteVersion {
public static void main(String[] args) {
Support.run("files.delete_version", () -> {
Support.step("bucket");
final B2Bucket bucket = Support.bucket();
final String id = bucket.getBucketId();

Support.step("upload two versions");
final String name = "st/delete/versioned.txt";
final byte[] firstBytes = Support.utf8("first version");
final byte[] secondBytes = Support.utf8("second version");
final B2FileVersion v1 = Support.upload(bucket, name, firstBytes, "text/plain", null);
final B2FileVersion v2 = Support.upload(bucket, name, secondBytes, "text/plain", null);

Support.step("delete the first version");
Support.client.deleteFileVersion(v1);

Support.step("the deleted version is gone");
final List<B2FileVersion> remaining = Support.versions(id, name);
Support.eq("versions left after deleting the first", 1, remaining.size());
Support.eq("the version that is left", v2.getFileId(), remaining.get(0).getFileId());
final B2Exception gone = Support.expectError("download of the deleted version",
() -> Support.downloadById(v1.getFileId()));
Support.eq("deleted version download status", 404, gone.getStatus());

Support.step("the other version survives");
Support.check(Arrays.equals(secondBytes, Support.downloadById(v2.getFileId()).bytes),
"the surviving version's bytes changed");
Support.eq("name listing", Arrays.asList(name),
Support.names(Support.client.fileNames(B2ListFileNamesRequest.builder(id).setPrefix(name).build())));

Support.step("delete the last version");
Support.client.deleteFileVersion(v2);
Support.eq("versions left after deleting both", 0, Support.versions(id, name).size());
Support.eq("names left after deleting both", 0,
Support.names(Support.client.fileNames(B2ListFileNamesRequest.builder(id).setPrefix(name).build())).size());
Support.eq("whole-bucket version listing", 0,
Support.names(Support.client.fileVersions(B2ListFileVersionsRequest.builder(id).build())).size());
});
}
}
48 changes: 48 additions & 0 deletions .sdkharness/tests/conformance/FilesDownloadById.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright 2026, Backblaze Inc. All Rights Reserved.
// License https://www.backblaze.com/using_b2_code.html

import com.backblaze.b2.client.structures.B2Bucket;
import com.backblaze.b2.client.structures.B2FileVersion;

import java.util.Arrays;

/**
* files.download_by_id. Run by ../run-conformance (see ../../README.md).
*
* <p>b2_download_file_by_id "downloads one file by providing the ID of the file", so with two versions
* of one name each fileId returns its own bytes, while download by name returns the most recent
* version.
* https://www.backblaze.com/apidocs/b2-download-file-by-id
* https://www.backblaze.com/apidocs/b2-download-file-by-name
*/
public class FilesDownloadById {
public static void main(String[] args) {
Support.run("files.download_by_id", () -> {
Support.step("bucket");
final B2Bucket bucket = Support.bucket();

Support.step("upload two versions");
final String name = "st/by-id/versioned.bin";
final byte[] first = Support.payload(2048, 3);
final byte[] second = Support.payload(3000, 4);
final B2FileVersion v1 = Support.upload(bucket, name, first, "application/octet-stream", null);
final B2FileVersion v2 = Support.upload(bucket, name, second, "application/octet-stream", null);
Support.check(!v1.getFileId().equals(v2.getFileId()), "two uploads of one name returned the same fileId");

Support.step("download first version by id");
final Support.Downloaded got1 = Support.downloadById(v1.getFileId());
Support.check(Arrays.equals(first, got1.bytes), "the first fileId did not return the first version's bytes");
Support.eq("first X-Bz-Content-Sha1", Support.sha1(first), got1.headers.getValueOrNull("X-Bz-Content-Sha1"));
Support.eq("first X-Bz-File-Id", v1.getFileId(), got1.headers.getValueOrNull("X-Bz-File-Id"));

Support.step("download second version by id");
final Support.Downloaded got2 = Support.downloadById(v2.getFileId());
Support.check(Arrays.equals(second, got2.bytes), "the second fileId did not return the second version's bytes");
Support.eq("second X-Bz-File-Id", v2.getFileId(), got2.headers.getValueOrNull("X-Bz-File-Id"));

Support.step("by name is the latest");
final Support.Downloaded byName = Support.downloadByName(bucket.getBucketName(), name);
Support.check(Arrays.equals(second, byName.bytes), "download by name did not return the most recent version");
});
}
}
58 changes: 58 additions & 0 deletions .sdkharness/tests/conformance/FilesDownloadContent.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// Copyright 2026, Backblaze Inc. All Rights Reserved.
// License https://www.backblaze.com/using_b2_code.html

import com.backblaze.b2.client.exceptions.B2Exception;
import com.backblaze.b2.client.structures.B2Bucket;
import com.backblaze.b2.client.structures.B2DownloadByNameRequest;
import com.backblaze.b2.client.structures.B2FileVersion;
import com.backblaze.b2.util.B2ByteRange;

import java.util.Arrays;

/**
* files.download_content. Run by ../run-conformance (see ../../README.md).
*
* <p>Download by name returns the stored bytes with the headers B2 documents (Content-Length,
* Content-Type, X-Bz-File-Id, X-Bz-File-Name, X-Bz-Content-Sha1); a Range request returns just that
* byte range; a name that does not exist is a 404 not_found.
* https://www.backblaze.com/apidocs/b2-download-file-by-name
*/
public class FilesDownloadContent {
public static void main(String[] args) {
Support.run("files.download_content", () -> {
Support.step("bucket");
final B2Bucket bucket = Support.bucket();

Support.step("upload");
final String name = "st/download/content.bin";
final byte[] data = Support.payload(4096, 2);
final B2FileVersion uploaded = Support.upload(bucket, name, data, "application/x-sdkharness", null);

Support.step("download by name");
final Support.Downloaded got = Support.downloadByName(bucket.getBucketName(), name);
Support.check(Arrays.equals(data, got.bytes), "downloaded bytes differ from the uploaded bytes");
Support.eq("X-Bz-Content-Sha1", Support.sha1(data), got.headers.getValueOrNull("X-Bz-Content-Sha1"));
Support.eq("Content-Length", String.valueOf(data.length), got.headers.getValueOrNull("Content-Length"));
Support.eq("Content-Type", "application/x-sdkharness", got.headers.getContentType());
Support.eq("X-Bz-File-Id", uploaded.getFileId(), got.headers.getValueOrNull("X-Bz-File-Id"));

Support.step("range");
final int first = 100;
final int last = 199;
final Support.Downloaded part = new Support.Downloaded();
Support.client.downloadByName(
B2DownloadByNameRequest.builder(bucket.getBucketName(), name).setRange(B2ByteRange.between(first, last)).build(),
(headers, in) -> {
part.headers = headers;
part.bytes = in.readAllBytes();
});
Support.check(Arrays.equals(Arrays.copyOfRange(data, first, last + 1), part.bytes),
"a Range of bytes " + first + "-" + last + " did not return exactly those bytes (got " + part.bytes.length + " bytes)");

Support.step("missing name");
final B2Exception missing = Support.expectError("download of a missing name",
() -> Support.downloadByName(bucket.getBucketName(), "st/download/no-such-file"));
Support.eq("missing name status", 404, missing.getStatus());
});
}
}
Loading
Loading