| // 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" |
| "time" |
| |
| "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/common/logging/gologger" |
| "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 |
| |
| 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{Name: "fuchsia/sdk/core/linux-amd64", Data: strings.NewReader("")}) |
| 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" |
| stmt, err := NewStmt(&Subject{ |
| Name: name, |
| Data: buf, |
| }) |
| 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{ |
| "sha256": "69bedde1f9c6fdc55debf4528e2204b63d68951d384f49b4b8c2c906da8e5dc0", |
| }, |
| }, |
| }, |
| } |
| 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) |
| } |
| } |
| |
| const fakeLUCIToken = "ya29.a0AfB_byAdIhZ9KWIh6vYkODl3VDfOHi1fNNmblLb9yp_W2CH7T23C0RTmpspLZ-EMgKO-UWGIMkm8ivmqF6j8iFPsEkW8Xywld4yI3AnFhCM6G7k433FRoUVfQkNUCOC03j_P1Q8gqK37VKPnuGmjQl_1RcPBzlv5XJpdaCgYKASISAQ8SFQGOcNnCKCsyK-Oe4wRl_HNBMAk2Rg0171" |
| |
| type testTokener struct { |
| token *oauth2.Token |
| } |
| |
| func (tt *testTokener) GetAccessToken(_ time.Duration) (*oauth2.Token, error) { |
| return tt.token, nil |
| } |
| |
| func TestLUCIToken(t *testing.T) { |
| ctx := context.Background() |
| var token string |
| |
| // Attempt to get a real token if possible. |
| if !testing.Short() { |
| var err error |
| token, err = LUCIToken(ctx) |
| if err != nil { |
| t.Logf("LUCI unable to get a real token, only possible when run locally: %v", err.Error()) |
| } |
| } |
| |
| // No token was found, which is unsurprising in most environments. |
| // Fallback to a stub token. This doesn't prove much except that the function isn't |
| // completely broken. |
| if token == "" { |
| stub := &oauth2.Token{ |
| AccessToken: fakeLUCIToken, |
| } |
| |
| var err error |
| token, err = luciTokenWithAuthenticator(ctx, &testTokener{token: stub}) |
| if err != nil { |
| t.Fatalf("LUCI did not return any token: %v", err) |
| } |
| } |
| |
| // All Google access tokens seem to start with ya29. |
| wantPrefix := "ya29" |
| if !strings.HasPrefix(token, 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{ |
| Name: "foopkg", |
| Data: strings.NewReader("bardata"), |
| }, |
| bundle: fakeBundle, |
| wantAccepted: true, |
| }, |
| } |
| |
| for _, test := range tests { |
| t.Run(test.name, func(t *testing.T) { |
| stmt, err := NewStmt(test.subject) |
| if err != nil { |
| t.Fatalf("couldn't create statement from subject: %v", err) |
| } |
| resp, err := UploadBundleToEndpoint( |
| context.Background(), |
| ts.URL, |
| fakeLUCIToken, |
| 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()) |
| } |