package alexa import ( "context" "encoding/json" "errors" "io" "net/http" "net/http/httptest" "strings" "testing" "time" ) type fakeValidator struct { err error } func (f *fakeValidator) Validate(ctx context.Context, r *http.Request, body []byte) error { return f.err } type fakeDispatcher struct { respFunc func(ctx context.Context, req Request) (Response, error) } func (f *fakeDispatcher) Dispatch(ctx context.Context, req Request) (Response, error) { if f.respFunc == nil { return NewTellResponse("ok"), nil } return f.respFunc(ctx, req) } const testSkillID = "amzn1.ask.skill.test" func validRequestBody(t *testing.T, skillID, requestType string, ts time.Time) []byte { t.Helper() req := Request{ Version: "1.0", Context: &Context{System: System{Application: Application{ApplicationID: skillID}}}, Request: RequestBody{ Type: requestType, RequestID: "amzn1.echo-api.request.test", Timestamp: ts.UTC().Format(time.RFC3339), Locale: "en-US", }, } b, err := json.Marshal(req) if err != nil { t.Fatalf("marshal request: %v", err) } return b } func TestHandlerServeHTTP(t *testing.T) { t.Run("happy path returns the dispatcher's response", func(t *testing.T) { body := validRequestBody(t, testSkillID, "LaunchRequest", time.Now()) h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{ respFunc: func(ctx context.Context, req Request) (Response, error) { return NewTellResponse("hello"), nil }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))) h.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String()) } var got Response if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("unmarshal response: %v", err) } if got.Response.OutputSpeech.Text != "hello" { t.Fatalf("OutputSpeech.Text = %q, want %q", got.Response.OutputSpeech.Text, "hello") } if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Fatalf("Content-Type = %q, want application/json", ct) } }) t.Run("signature validation failure returns 401 without dispatching", func(t *testing.T) { dispatched := false body := validRequestBody(t, testSkillID, "LaunchRequest", time.Now()) h := NewHandler(&fakeValidator{err: errors.New("bad signature")}, testSkillID, &fakeDispatcher{ respFunc: func(ctx context.Context, req Request) (Response, error) { dispatched = true return NewTellResponse("ok"), nil }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))) h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } if dispatched { t.Fatal("dispatcher should not be called when signature validation fails") } }) t.Run("malformed json body returns 400", func(t *testing.T) { h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("not json")) h.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) } }) t.Run("stale timestamp returns 401", func(t *testing.T) { body := validRequestBody(t, testSkillID, "LaunchRequest", time.Now().Add(-10*time.Minute)) h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))) h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } }) t.Run("application id mismatch returns 401", func(t *testing.T) { body := validRequestBody(t, "some-other-skill", "LaunchRequest", time.Now()) h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))) h.ServeHTTP(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } }) t.Run("dispatcher error returns 500", func(t *testing.T) { body := validRequestBody(t, testSkillID, "IntentRequest", time.Now()) h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{ respFunc: func(ctx context.Context, req Request) (Response, error) { return Response{}, errors.New("boom") }, }) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(body))) h.ServeHTTP(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) } }) t.Run("body read error returns 400", func(t *testing.T) { h := NewHandler(&fakeValidator{}, testSkillID, &fakeDispatcher{}) rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/", io.NopCloser(&errReader{})) h.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) } }) } type errReader struct{} func (e *errReader) Read(p []byte) (int, error) { return 0, errors.New("read failed") }