Repository navigation
feat(golang): every failure a guest reports is a Diagnostic that unwraps to its sentinel #1110
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,107 @@ | ||
| package auth | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| ) | ||
|
|
||
| // wantDiagnostic asserts that err carries a *Diagnostic with code and a | ||
| // message, and returns it. | ||
| func wantDiagnostic(t *testing.T, err error, code string) *Diagnostic { | ||
| t.Helper() | ||
| var d *Diagnostic | ||
| if !errors.As(err, &d) { | ||
| t.Fatalf("%v carries no Diagnostic", err) | ||
| } | ||
| if d.Code != code { | ||
| t.Fatalf("code = %q, want %q (%v)", d.Code, code, err) | ||
| } | ||
| if d.Message == "" { | ||
| t.Fatalf("%s has no message", code) | ||
| } | ||
| return d | ||
| } | ||
|
|
||
| // wantCode is wantDiagnostic for a test that needs only the code. | ||
| func wantCode(t *testing.T, err error, code string) { | ||
| t.Helper() | ||
| _ = wantDiagnostic(t, err, code) | ||
| } | ||
|
|
||
| // Each kind the profile store reports is its sentinel for errors.Is and a | ||
| // Diagnostic for errors.As. The strategies' kinds are asserted beside the | ||
| // tests that provoke them, in strategy_test.go. ErrInvalidFilename is not | ||
| // here: the store refuses every filename the guest would before asking it. | ||
| func TestEachStoreKindCarriesItsDiagnostic(t *testing.T) { | ||
| ctx := context.Background() | ||
| dir, s := profile(t) | ||
| ws, err := s.WorkspaceStore(ctx, wsB) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| cases := []struct { | ||
| name string | ||
| run func() error | ||
| want error | ||
| code string | ||
| }{ | ||
| {"no current workspace", func() error { | ||
| _, err := s.CurrentWorkspace(ctx) | ||
| return err | ||
| }, ErrNoCurrentWorkspace, "stack_profile::no_current_workspace"}, | ||
| {"a workspace with no directory", func() error { | ||
| return s.SetCurrentWorkspace(ctx, "CCCCCCCCCCCCCCCC") | ||
| }, ErrWorkspaceNotFound, "stack_profile::workspace_not_found"}, | ||
| {"a workspace id that is a path", func() error { | ||
| return s.SetCurrentWorkspace(ctx, "../escape") | ||
| }, ErrInvalidWorkspaceID, "stack_profile::invalid_workspace_id"}, | ||
| {"a file that is not there", func() error { | ||
| _, err := ws.Token(ctx) | ||
| return err | ||
| }, ErrNotFound, "stack_profile::not_found"}, | ||
| {"a file that is not JSON", func() error { | ||
| write(t, filepath.Join(dir, "workspaces", wsB, "auth.json"), "{not json") | ||
| _, err := ws.Token(ctx) | ||
| return err | ||
| }, ErrInvalid, "stack_profile::json"}, | ||
| {"a file that is a directory", func() error { | ||
| if err := os.MkdirAll(filepath.Join(dir, "workspaces", wsB, "secretkey.json"), 0o700); err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| _, _, err := ws.SecretKey(ctx) | ||
| return err | ||
| }, ErrIO, "stack_profile::io"}, | ||
| } | ||
| for _, tc := range cases { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| err := tc.run() | ||
| if !errors.Is(err, tc.want) { | ||
| t.Fatalf("%v, want %v", err, tc.want) | ||
| } | ||
| wantCode(t, err, tc.code) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| // A profile file that is not JSON says where: the line and column, never | ||
| // the parser's message, which could quote the file. | ||
| func TestAProfileJSONErrorNamesTheLine(t *testing.T) { | ||
| ctx := context.Background() | ||
| dir, s := profile(t) | ||
| write(t, filepath.Join(dir, "workspaces", wsB, "auth.json"), "{\n \"access_token\": 7\n}") | ||
| ws, err := s.WorkspaceStore(ctx, wsB) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| _, err = ws.Token(ctx) | ||
| d := wantDiagnostic(t, err, "stack_profile::json") | ||
| if d.Fields["line"] != uint64(2) { | ||
| t.Errorf("fields = %v, want line 2", d.Fields) | ||
| } | ||
| if d.Help == "" { | ||
| t.Error("a JSON error gives no help") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -250,6 +250,7 @@ func TestUsageLimitIsPreservedAcrossGuest(t *testing.T) { | |
| if !errors.Is(err, ErrUsageLimit) { | ||
| t.Fatalf("Token error = %v, want %v", err, ErrUsageLimit) | ||
| } | ||
| wantCode(t, err, "stack_auth::usage_limit_exceeded") | ||
| } | ||
|
|
||
| func TestDeviceRefreshReportsInvalidClient(t *testing.T) { | ||
|
|
@@ -278,6 +279,7 @@ func TestDeviceRefreshReportsInvalidClient(t *testing.T) { | |
| if !errors.Is(err, ErrInvalidClient) { | ||
| t.Fatalf("Token error = %v, want %v", err, ErrInvalidClient) | ||
| } | ||
| wantCode(t, err, "stack_auth::invalid_client") | ||
| } | ||
|
|
||
| // Match stack-auth's AutoStrategy order: an access key wins over a stored | ||
|
|
@@ -459,6 +461,8 @@ func TestAutoUsesEnvironmentPresenceAndProfileExistence(t *testing.T) { | |
| } | ||
| if _, err := profile.AccessKey(context.Background(), "invalid", "CSAKtestKeyId.testKeySecret"); !errors.Is(err, ErrConfig) { | ||
| t.Fatalf("malformed CRN for access key: error = %v, want %v", err, ErrConfig) | ||
| } else { | ||
| wantCode(t, err, "stack_auth::invalid_crn") | ||
| } | ||
| provider := OIDCProviderFunc(func(context.Context) (string, error) { return "", nil }) | ||
| if _, err := profile.OIDC(context.Background(), "invalid", provider); !errors.Is(err, ErrConfig) { | ||
|
|
@@ -587,6 +591,7 @@ func TestDeviceRefreshReportsInvalidGrant(t *testing.T) { | |
| if !errors.Is(err, ErrInvalidGrant) { | ||
| t.Fatalf("Token error = %v, want ErrInvalidGrant", err) | ||
| } | ||
| wantCode(t, err, "stack_auth::invalid_grant") | ||
| } | ||
|
|
||
| // The edge in front of production CTS answers a request whose User-Agent is | ||
|
|
@@ -639,8 +644,8 @@ func isStackAuthGoAgent(ua string) bool { | |
| return ok && version != "" && !strings.ContainsAny(version, " ()") | ||
| } | ||
|
|
||
| // Only a status code crosses the guest ABI, so a refused exchange must still | ||
| // say which HTTP status refused it, and never carry the response body. | ||
| // A refused exchange says which HTTP status refused it, and never carries | ||
| // the response body: not in the message, and not in the Diagnostic. | ||
| func TestAuthTransportErrorNamesTheHTTPStatusNotTheBody(t *testing.T) { | ||
| guestOrSkip(t) | ||
| server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
|
|
@@ -663,9 +668,16 @@ func TestAuthTransportErrorNamesTheHTTPStatusNotTheBody(t *testing.T) { | |
| if !errors.Is(err, ErrTransport) { | ||
| t.Fatalf("Token error = %v, want ErrTransport", err) | ||
| } | ||
| if want := "cipherstash: auth transport failed: HTTP 403"; err.Error() != want { | ||
| if want := "Server error: 403: HTTP 403"; err.Error() != want { | ||
| t.Fatalf("Token error = %q, want %q", err, want) | ||
| } | ||
| var d *Diagnostic | ||
| if !errors.As(err, &d) || d.Code != "stack_auth::server_error" { | ||
| t.Fatalf("Token error = %#v, want a stack_auth::server_error Diagnostic", err) | ||
| } | ||
| if shown := fmt.Sprintf("%+v", *d); strings.Contains(shown, "nginx") || strings.Contains(shown, "testKeySecret") { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fix in a follow-up: No Go test checks that the host's transport error text stays out of the auth Impact: When the Evidence:
Fix: Add the test below next to The test uses
If the package uses different names for these jobs, change the test to use them. func TestAuthTransportErrorTextIsNotInTheDiagnostic(t *testing.T) {
guestOrSkip(t)
ctx := context.Background()
const marker = "leak-marker-transport"
rt := roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New(`Post "https://cts.invalid/token?secret=` + marker + `": proxyconnect tcp: refused`)
})
profile, err := Open(ctx, t.TempDir(), WithRoundTripper(rt))
if err != nil {
t.Fatal(err)
}
defer profile.Close()
strategy, err := profile.AccessKey(ctx, testCRN, "CSAKtestKeyId.testKeySecret", WithBaseURL("https://cts.invalid"))
if err != nil {
t.Fatal(err)
}
defer strategy.Close()
_, err = strategy.Token(ctx)
var d *Diagnostic
if !errors.Is(err, ErrTransport) || !errors.As(err, &d) {
t.Fatalf("Token error = %#v, want a Diagnostic over ErrTransport", err)
}
if shown := fmt.Sprintf("%v %+v", err, *d); strings.Contains(shown, marker) {
t.Fatalf("the host's transport error is in the Diagnostic: %s", shown)
}
}Found by 1 model: claude |
||
| t.Fatalf("the response body is in the Diagnostic: %s", shown) | ||
| } | ||
| } | ||
|
|
||
| // A transport failure with no HTTP response at all stays the bare sentinel: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| package encrypt | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "testing" | ||
|
|
||
| "github.com/cipherstash/vitaminc/bindings/go/vcffi" | ||
| ) | ||
|
|
||
| // wantDiagnostic asserts that err carries a *Diagnostic with code and a | ||
| // message, and returns it. | ||
| func wantDiagnostic(t *testing.T, err error, code string) *Diagnostic { | ||
| t.Helper() | ||
| var d *Diagnostic | ||
| if !errors.As(err, &d) { | ||
| t.Fatalf("%v carries no Diagnostic", err) | ||
| } | ||
| if d.Code != code { | ||
| t.Fatalf("code = %q, want %q (%v)", d.Code, code, err) | ||
| } | ||
| if d.Message == "" { | ||
| t.Fatalf("%s has no message", code) | ||
| } | ||
| return d | ||
| } | ||
|
|
||
| // wantCode is wantDiagnostic for a test that needs only the code. | ||
| func wantCode(t *testing.T, err error, code string) { | ||
| t.Helper() | ||
| _ = wantDiagnostic(t, err, code) | ||
| } | ||
|
|
||
| // The guest's own refusals, which no ZeroKMS stub reaches: input that is | ||
| // not the codec, and an operation before init. | ||
| func TestGuestRefusalsCarryTheirDiagnostic(t *testing.T) { | ||
| ctx := context.Background() | ||
| c := rawInstance(t) | ||
| _, err := c.call(ctx, func(inst *instance) ([]byte, error) { | ||
| return inst.call(ctx, inst.planCheck, buf([]byte{0xff})) | ||
| }) | ||
| if !errors.Is(err, ErrEncoding) { | ||
| t.Fatalf("plan check of bytes that are not the codec: %v, want ErrEncoding", err) | ||
| } | ||
| wantCode(t, err, "stack_guest_abi::malformed_input") | ||
|
|
||
| selector, err := vcffi.Marshal(KeysetName("tenant-b").selector()) | ||
| if err != nil { | ||
| t.Fatal(err) | ||
| } | ||
| _, err = c.call(ctx, func(inst *instance) ([]byte, error) { | ||
| return inst.call(ctx, inst.keyset, buf(selector)) | ||
| }) | ||
| if !errors.Is(err, ErrState) { | ||
| t.Fatalf("a keyset before init: %v, want ErrState", err) | ||
| } | ||
| if d := wantDiagnostic(t, err, "stack_guest_abi::out_of_order"); d.Help == "" { | ||
| t.Error("an out-of-order call gives no help") | ||
| } | ||
| } | ||
|
|
||
| // A guest built before se_last_error fails as it always did: the bare | ||
| // sentinel, and nothing else. | ||
| func TestAGuestWithoutLastErrorFailsWithTheBareSentinel(t *testing.T) { | ||
| ctx := context.Background() | ||
| c := rawInstance(t) | ||
| c.inst.exports.LastError = nil | ||
| _, err := c.call(ctx, func(inst *instance) ([]byte, error) { | ||
| return inst.call(ctx, inst.planCheck, buf([]byte{0xff})) | ||
| }) | ||
| if err != ErrEncoding { | ||
| t.Fatalf("err = %#v, want the bare ErrEncoding", err) | ||
| } | ||
| var d *Diagnostic | ||
| if errors.As(err, &d) { | ||
| t.Fatalf("a Diagnostic from a guest with no se_last_error: %+v", d) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fix in a follow-up: The malformed access key check at lines 457-460 does not check the
Diagnosticcode or look for the key text.Impact:
profile.AutosendsCS_CLIENT_ACCESS_KEYto the guest in the strategy config. The guest'screaterefuses a key that does not parse. This is the only guest call that receives an access key and fails before any HTTP request. If a later change puts the key text in that error, the text reachesDiagnostic.MessageorFields. No test fails.Evidence:
create, because theauthmodule exists only on wasm32 (auth/guest/src/lib.rs:101-102). So only this Go test runs the real code."not-a-key". It checks onlyerrors.Is(err, ErrConfig).wantCodecall. The access key check does not.Fix: Replace the check at lines 457-460 with the code below. The new code sets
CS_CLIENT_ACCESS_KEYto a marker string and callsprofile.Auto. It expectsErrConfig, codestack_auth::invalid_access_key, and no marker text in the error string or theDiagnostic.Before you paste the code, compare it with lines 457-460:
err =. Iferris not declared before this point, change it toerr :=.The code also calls a helper
wantDiagnostic(t, err, code). The helper must fail the test unlesserrcontains a*Diagnosticwith that code. It must return that*Diagnostic. If theauthtests have no helper with this name, add one next towantCode.Found by 1 model: claude