| // 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()) |
| } |