blob: ad17afa602762dd7023160633f60329a56309bc5 [file] [log] [blame]
// Copyright 2023 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// TODO(b/299967645): Test errors, not just happy path.
package bcid
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"strings"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
spb "github.com/in-toto/attestation/go/v1"
"go.chromium.org/luci/cipd/common"
"go.chromium.org/luci/common/logging/gologger"
"go.fuchsia.dev/infra/cmd/recipe_wrapper/env"
"go.fuchsia.dev/infra/cmd/recipe_wrapper/recipes"
"golang.org/x/oauth2"
)
func ctx() context.Context {
ctx := context.Background()
if testing.Verbose() {
return gologger.StdConfig.Use(ctx)
}
return ctx
}
// These are read from a test data file because the line lengths are enormous.
var fakeInTotoOutput []byte
var fakeVerificationResponse []byte
var buildEnv = &env.Build{RecipesExe: &recipes.Repo{}}
type testAttestOutputter struct{}
func (tao *testAttestOutputter) CombinedOutput(ctx context.Context, _ []byte, args ...string) ([]byte, error) {
if args[0] != "sign_intoto_statement" {
panic("only sign_intoto_statement is supported")
}
return fakeInTotoOutput, nil
}
// outputter chooses a real version of a tool if it is available and the test
// isn't running in t.Short. Otherwise, returns a stubbed outputter.
func outputter(t *testing.T, ctx context.Context, tool string, args ...string) CombinedOutputer {
t.Helper()
// TODO(cflewis): Find a way to make this work on a LUCI builder.
// Tools on LUCI usually aren't provided inside a path but via
// argument/envvar.
_, pathErr := exec.LookPath(tool)
// Check that the test isn't running in a sandbox.
resp, _ := http.Get("https://example.com")
var real, stub CombinedOutputer
switch tool {
case "attestation_tool":
real = &attestOutputter{}
stub = &testAttestOutputter{}
default:
t.Fatalf("no definition for real/stub commander for %v", tool)
}
if !testing.Short() && pathErr == nil && resp.StatusCode == http.StatusOK {
return real
}
return stub
}
func TestAttest(t *testing.T) {
ctx := ctx()
stmt, err := NewStmt(&Subject{Pkg: &common.Pin{
PackageName: "fuchsia/sdk/core/linux-amd64",
InstanceID: "NubrCS3MOEEOGG1aPZJnyK3AjZnPMTVC6Of5leb7vHkC"},
Data: strings.NewReader("")}, buildEnv)
if err != nil {
t.Fatalf("can't initialize statement")
}
bundle, err := attestWithOutputter(ctx, outputter(t, ctx, "attestation_tool"), stmt, TestKeyID)
if err != nil {
t.Fatalf("attestWithCommander() error %v, want nil error", err)
}
// A real invocation of the attestation_tool is non-deterministic because it appears
// to rely on the clock time as an input.
// Instead of relying on the data itself, just check that the data was formatted as
// expected. This validates that attestation_tool did work.
if bundle.ContentType != "application/vnd.in-toto+json" ||
bundle.Signatures[0].KeyID != "gcpkms://"+TestKeyID || bundle.Signatures[0].Sig == "" {
t.Errorf("InToto struct did not follow expected format\nContentType = %q\nKey ID = %q\nSig = %q",
bundle.ContentType, bundle.Signatures[0].KeyID, bundle.Signatures[0].Sig)
}
}
func fakeZip(t *testing.T, buf *bytes.Buffer, data string) *zip.Writer {
w := zip.NewWriter(buf)
f, err := w.Create(data)
if err != nil {
t.Fatalf("can't add to zip: %v", err)
}
_, err = f.Write([]byte("bar"))
if err != nil {
t.Fatalf("can't write to zip: %v", err)
}
return w
}
// CIPD packages are zip files so that functionality needs its own test.
func TestNewStmtWithZip(t *testing.T) {
buf := new(bytes.Buffer)
// Note that changing these input values will change the expected SHA and fail the test.
w := fakeZip(t, buf, "foo")
if err := w.Close(); err != nil {
t.Fatalf("can't close writer to zip: %v", err)
}
const name = "alice/bob.pkg"
const instanceID = "instance_id"
stmt, err := NewStmt(&Subject{
Pkg: &common.Pin{
PackageName: name,
InstanceID: "instance_id",
},
Data: buf,
}, buildEnv)
if err != nil {
t.Errorf("got %q, want nil err", err)
}
want := &spb.Statement{
Type: StmtType,
PredicateType: PredicateType,
Subject: []*spb.ResourceDescriptor{
{
Name: name,
Digest: map[string]string{
"cipd_instance_id": instanceID,
},
},
},
}
if diff := cmp.Diff(stmt, want,
// Predicate is a bunch of dynamically typed values.
// It's a pain to write a comparer for this, so just ignore it and make sure
// it's not nil.
cmpopts.IgnoreFields(spb.Statement{}, "Predicate"),
cmpopts.IgnoreUnexported(spb.Statement{}),
cmpopts.IgnoreUnexported(spb.ResourceDescriptor{})); diff != "" || stmt.Predicate == nil {
t.Errorf("got diff, want no diff: Diff:\n%v", diff)
}
}
type repo struct {
repo, sha string
}
func (r *repo) LuciexeCommand() ([]string, error) {
return nil, nil
}
func (r *repo) Repo() string { return r.repo }
func (r *repo) SHA1() string { return r.sha }
func TestNewStmtConfigSource(t *testing.T) {
got, err := NewStmt(&Subject{
Pkg: &common.Pin{PackageName: "Foo", InstanceID: "Bar"},
Data: strings.NewReader("foo"),
}, &env.Build{RecipesExe: &repo{repo: "foo", sha: "123456"}})
if err != nil {
t.Fatalf("unable to construct new statement: %v", err)
}
want := "git+foo@123456"
// No, there is no better way to navigate through this structure.
// Yes, it sucks.
if got.Predicate.GetFields()["buildDefinition"].
GetStructValue().GetFields()["externalParameters"].
GetStructValue().GetFields()["buildConfigSource"].
GetStructValue().GetFields()["repository"].GetStringValue() != want {
t.Errorf("got %v, want %v", got, want)
}
}
const fakeLUCIToken = "ya29.a0AfB_byAdIhZ9KWIh6vYkODl3VDfOHi1fNNmblLb9yp_W2CH7T23C0RTmpspLZ-EMgKO-UWGIMkm8ivmqF6j8iFPsEkW8Xywld4yI3AnFhCM6G7k433FRoUVfQkNUCOC03j_P1Q8gqK37VKPnuGmjQl_1RcPBzlv5XJpdaCgYKASISAQ8SFQGOcNnCKCsyK-Oe4wRl_HNBMAk2Rg0171"
type testTokenSource struct {
}
func (tts *testTokenSource) Token() (*oauth2.Token, error) {
return &oauth2.Token{AccessToken: fakeLUCIToken}, nil
}
func TestLUCIToken(t *testing.T) {
ctx := context.Background()
var tokenSource oauth2.TokenSource
// Attempt to get a real token if possible.
if !testing.Short() {
var err error
tokenSource, err = LUCITokenSource(ctx)
if err != nil || tokenSource == nil {
t.Logf("LUCI unable to get a real token source, this is expected when run non-locally and can be ignored: %v",
err.Error())
// There's no real token source, so use the test one instead.
tokenSource = &testTokenSource{}
}
}
// All Google access tokens seem to start with ya29.
token, err := tokenSource.Token()
if err != nil {
t.Fatalf("unable to get token from source")
}
wantPrefix := "ya29"
if !strings.HasPrefix(token.AccessToken, wantPrefix) {
t.Errorf("got %v, want start of ya29 %v", token, wantPrefix)
}
}
func TestUploadBundle(t *testing.T) {
// It's simply too difficult to test hitting the BCID API
// given the allowlists, and OAuth token exchanges (with private scopes) required.
// Bring up a test server and just check that the requests are reasonably
// formatted.
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, fmt.Sprintf("can't read body: %v", err), http.StatusBadRequest)
}
defer r.Body.Close()
var req sciloReq
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("can't unmarshal request to valid SCILo request: %v\nBody: %s", err, body), http.StatusBadRequest)
}
fmt.Fprintln(w, string(fakeVerificationResponse))
}))
defer ts.Close()
fakeBundle, err := UnmarshalBundle(bytes.NewReader(fakeInTotoOutput))
if err != nil {
t.Fatalf("couldn't marshal fake bundle: %v", err)
}
tests := []struct {
name string
subject *Subject
bundle *InTotoBundle
wantAccepted bool
}{
{
name: "A good statement and bundle should produce a good response",
subject: &Subject{
Pkg: &common.Pin{
PackageName: "foopkg",
InstanceID: "bar_instance",
},
Data: strings.NewReader("bazdata"),
},
bundle: fakeBundle,
wantAccepted: true,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
stmt, err := NewStmt(test.subject, buildEnv)
if err != nil {
t.Fatalf("couldn't create statement from subject: %v", err)
}
resp, err := UploadToSCILoEndpoint(
context.Background(),
ts.URL,
&testTokenSource{},
stmt,
test.bundle,
)
if err != nil {
t.Errorf("got error %v, want nil error", err)
}
if resp == nil {
t.Error("got nil response, want valid response")
}
if test.wantAccepted && !(resp.Allowed && resp.RejectionMessage == "") {
t.Errorf("wanted accepted response with no rejection message, got %+v", resp)
}
})
}
}
func mustRead(filename string) []byte {
f, err := os.Open(filename)
if err != nil {
fmt.Fprintf(os.Stderr, "unable to open testdata: %v", err)
os.Exit(1)
}
data, err := io.ReadAll(f)
if err != nil {
fmt.Fprintf(os.Stderr, "unable to read in-toto testdata: %v", err)
os.Exit(1)
}
return data
}
func TestMain(m *testing.M) {
fakeInTotoOutput = mustRead("testdata/in-toto-output.json")
fakeVerificationResponse = mustRead("testdata/verification-response.json")
os.Exit(m.Run())
}