This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package onboardingapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/itworx/pulse/internal/audit"
|
||||
"github.com/itworx/pulse/internal/auth"
|
||||
"github.com/itworx/pulse/internal/correlation"
|
||||
"github.com/itworx/pulse/internal/onboarding"
|
||||
"github.com/itworx/pulse/internal/problem"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
Status(context.Context) (onboarding.Status, error)
|
||||
Complete(context.Context, onboarding.Choice) (onboarding.Status, error)
|
||||
}
|
||||
|
||||
type Handler struct {
|
||||
Service Service
|
||||
Audit audit.Store
|
||||
}
|
||||
|
||||
func (h Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
principal, ok := auth.PrincipalFromContext(r.Context())
|
||||
if !ok {
|
||||
fail(w, r, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required.")
|
||||
return
|
||||
}
|
||||
if strings.TrimSuffix(r.URL.Path, "/") != "/api/v1/onboarding" {
|
||||
fail(w, r, http.StatusNotFound, "NOT_FOUND", "Onboarding not found.")
|
||||
return
|
||||
}
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
status, err := h.Service.Status(r.Context())
|
||||
if err != nil {
|
||||
fail(w, r, http.StatusServiceUnavailable, "ONBOARDING_UNAVAILABLE", "Onboarding status is unavailable.")
|
||||
return
|
||||
}
|
||||
write(w, http.StatusOK, status)
|
||||
case http.MethodPost:
|
||||
if !auth.Allows(principal.Role, auth.PermissionAdmin) {
|
||||
fail(w, r, http.StatusForbidden, "FORBIDDEN", "Only administrators can complete onboarding.")
|
||||
return
|
||||
}
|
||||
var choice onboarding.Choice
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&choice); err != nil {
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_CHOICES", "Onboarding choices are invalid.")
|
||||
return
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
fail(w, r, http.StatusBadRequest, "INVALID_CHOICES", "Onboarding choices are invalid.")
|
||||
return
|
||||
}
|
||||
status, err := h.Service.Complete(r.Context(), choice)
|
||||
if err != nil {
|
||||
fail(w, r, http.StatusBadRequest, "ONBOARDING_FAILED", "Onboarding kon niet veilig worden afgerond.")
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
if err := h.Audit.Append(r.Context(), audit.Event{Actor: principal.Subject, Action: "onboarding.complete", ResourceType: "system", ResourceID: "00000000-0000-0000-0000-000000000000", Result: "success", CorrelationID: correlation.FromContext(r.Context())}); err != nil {
|
||||
fail(w, r, http.StatusServiceUnavailable, "AUDIT_UNAVAILABLE", "The onboarding audit event could not be recorded.")
|
||||
return
|
||||
}
|
||||
}
|
||||
write(w, http.StatusOK, status)
|
||||
default:
|
||||
fail(w, r, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "This method is not supported.")
|
||||
}
|
||||
}
|
||||
|
||||
func fail(w http.ResponseWriter, r *http.Request, status int, code, detail string) {
|
||||
problem.Write(w, r, status, code, http.StatusText(status), detail, nil)
|
||||
}
|
||||
|
||||
func write(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package onboardingapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/itworx/pulse/internal/audit"
|
||||
"github.com/itworx/pulse/internal/auth"
|
||||
"github.com/itworx/pulse/internal/onboarding"
|
||||
)
|
||||
|
||||
type fakeService struct {
|
||||
status onboarding.Status
|
||||
completed onboarding.Status
|
||||
choices onboarding.Choice
|
||||
}
|
||||
|
||||
func (f *fakeService) Status(context.Context) (onboarding.Status, error) { return f.status, nil }
|
||||
func (f *fakeService) Complete(_ context.Context, choice onboarding.Choice) (onboarding.Status, error) {
|
||||
f.choices = choice
|
||||
return f.completed, nil
|
||||
}
|
||||
|
||||
func onboardingRequest(method, body string, principal auth.Principal) *http.Request {
|
||||
request := httptest.NewRequest(method, "/api/v1/onboarding", strings.NewReader(body))
|
||||
return request.WithContext(auth.WithPrincipal(request.Context(), principal))
|
||||
}
|
||||
|
||||
func TestStatusRequiresAuthentication(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
(Handler{Service: &fakeService{}}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/onboarding", nil))
|
||||
if response.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status=%d, want 401", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewerCannotCompleteOnboarding(t *testing.T) {
|
||||
service := &fakeService{}
|
||||
response := httptest.NewRecorder()
|
||||
(Handler{Service: service}).ServeHTTP(response, onboardingRequest(http.MethodPost, `{}`, auth.Principal{Subject: "viewer", Role: auth.RoleViewer}))
|
||||
if response.Code != http.StatusForbidden || service.choices != (onboarding.Choice{}) {
|
||||
t.Fatalf("status=%d choices=%#v", response.Code, service.choices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministratorCompletesAndAuditsOnboarding(t *testing.T) {
|
||||
service := &fakeService{completed: onboarding.Status{State: onboarding.State{Completed: true, Step: "complete"}}}
|
||||
auditStore := &audit.MemoryStore{}
|
||||
response := httptest.NewRecorder()
|
||||
(Handler{Service: service, Audit: auditStore}).ServeHTTP(response, onboardingRequest(http.MethodPost, `{"dashboard":"default","rules":"default"}`, auth.Principal{Subject: "admin", Role: auth.RoleAdministrator}))
|
||||
if response.Code != http.StatusOK || service.choices.Dashboard != "default" || len(auditStore.Events) != 1 {
|
||||
t.Fatalf("status=%d choices=%#v audit=%d", response.Code, service.choices, len(auditStore.Events))
|
||||
}
|
||||
var status onboarding.Status
|
||||
if err := json.Unmarshal(response.Body.Bytes(), &status); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !status.State.Completed {
|
||||
t.Fatal("completion state missing")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user