Qué cambió — paso 14
Qué cambió · paso-13 → paso-14

Cambios de la lección 14

Todo lo que cambia en tienda/pasos/paso-14 respecto al paso anterior. Vuelve a la lección: 14. Tests en Go: del dominio a Keycloak real.

6 archivos cambian. En verde lo que se añade; en rojo lo que se quita. go.sum no se muestra.

go.mod modificado · +56 −2
@@ -5,16 +5,70 @@
 require (
 	github.com/Nerzal/gocloak/v14 v14.0.4
 	github.com/coreos/go-oidc/v3 v3.21.0
+	github.com/testcontainers/testcontainers-go v0.44.0
 	golang.org/x/oauth2 v0.37.0
 )
 
 require (
-	github.com/go-jose/go-jose/v4 v4.1.4 // indirect
+	dario.cat/mergo v1.0.2 // indirect
+	github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
+	github.com/Microsoft/go-winio v0.6.2 // indirect
+	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+	github.com/cespare/xxhash/v2 v2.3.0 // indirect
+	github.com/containerd/errdefs v1.0.0 // indirect
+	github.com/containerd/errdefs/pkg v0.3.0 // indirect
+	github.com/containerd/log v0.1.0 // indirect
+	github.com/containerd/platforms v0.2.1 // indirect
+	github.com/cpuguy83/dockercfg v0.3.2 // indirect
+	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/distribution/reference v0.6.0 // indirect
+	github.com/docker/go-connections v0.7.0 // indirect
+	github.com/docker/go-units v0.5.0 // indirect
+	github.com/ebitengine/purego v0.10.1 // indirect
+	github.com/felixge/httpsnoop v1.1.0 // indirect
+	github.com/go-logr/logr v1.4.3 // indirect
+	github.com/go-logr/stdr v1.2.2 // indirect
+	github.com/go-ole/go-ole v1.3.0 // indirect
+	github.com/google/uuid v1.6.0 // indirect
+	github.com/klauspost/compress v1.18.6 // indirect
+	github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
+	github.com/magiconair/properties v1.8.10 // indirect
+	github.com/moby/docker-image-spec v1.3.1 // indirect
+	github.com/moby/go-archive v0.2.0 // indirect
+	github.com/moby/moby/api v1.55.0 // indirect
+	github.com/moby/moby/client v0.5.0 // indirect
+	github.com/moby/patternmatcher v0.6.1 // indirect
+	github.com/moby/sys/sequential v0.7.0 // indirect
+	github.com/moby/sys/user v0.4.0 // indirect
+	github.com/moby/sys/userns v0.1.0 // indirect
+	github.com/moby/term v0.5.2 // indirect
+	github.com/opencontainers/go-digest v1.0.0 // indirect
+	github.com/opencontainers/image-spec v1.1.1 // indirect
+	github.com/pmezard/go-difflib v1.0.0 // indirect
+	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
+	github.com/shirou/gopsutil/v4 v4.26.6 // indirect
+	github.com/sirupsen/logrus v1.9.4 // indirect
+	github.com/stretchr/testify v1.11.1 // indirect
+	github.com/tklauser/go-sysconf v0.4.0 // indirect
+	github.com/tklauser/numcpus v0.12.0 // indirect
+	github.com/yusufpapurcu/wmi v1.2.4 // indirect
+	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
+	go.opentelemetry.io/otel v1.44.0 // indirect
+	go.opentelemetry.io/otel/metric v1.44.0 // indirect
+	go.opentelemetry.io/otel/trace v1.44.0 // indirect
+	golang.org/x/crypto v0.54.0 // indirect
+	golang.org/x/sys v0.47.0 // indirect
+	gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
+require (
+	github.com/go-jose/go-jose/v4 v4.1.4
 	github.com/go-resty/resty/v2 v2.17.2 // indirect
 	github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
 	github.com/opentracing/opentracing-go v1.2.0 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/segmentio/ksuid v1.0.4 // indirect
 	golang.org/x/mod v0.35.0 // indirect
-	golang.org/x/net v0.55.0 // indirect
+	golang.org/x/net v0.56.0 // indirect
 )
internal/adaptadores/keycloak/keycloak_test.go nuevo · +33 −0
@@ -0,0 +1,33 @@
+package keycloak_test
+
+import (
+	"slices"
+	"testing"
+
+	"tienda/internal/adaptadores/keycloak"
+	"tienda/internal/apiauth"
+	"tienda/internal/pedidos"
+)
+
+// La traducción roles de Keycloak → permisos del dominio es una tabla: se
+// prueba como tal, sin tokens.
+func TestActorFrom(t *testing.T) {
+	tests := []struct {
+		name string
+		p    apiauth.Principal
+		want []pedidos.Permission
+	}{
+		{"cliente", apiauth.Principal{Roles: []string{"cliente"}}, []pedidos.Permission{pedidos.PermBuy}},
+		{"admin", apiauth.Principal{Roles: []string{"admin"}}, []pedidos.Permission{pedidos.PermBuy, pedidos.PermManage}},
+		{"servicio de facturación", apiauth.Principal{APIRoles: []string{"facturar"}}, []pedidos.Permission{pedidos.PermInvoice}},
+		{"sin roles de la tienda", apiauth.Principal{Roles: []string{"offline_access"}}, nil},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got := keycloak.ActorFrom(&tt.p).Permissions
+			if !slices.Equal(got, tt.want) {
+				t.Errorf("permisos = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
internal/adaptadores/rest/rest_test.go nuevo · +97 −0
@@ -0,0 +1,97 @@
+package rest_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"slices"
+	"strings"
+	"testing"
+
+	"tienda/internal/adaptadores/memoria"
+	"tienda/internal/adaptadores/rest"
+	"tienda/internal/pedidos"
+)
+
+// fakeIdentity sustituye a Keycloak: el test decide quién llama y con qué
+// scopes. Si no hay actor, responde 401 como haría el adaptador real.
+type fakeIdentity struct {
+	actor  *pedidos.Actor
+	scopes []string
+}
+
+func (f fakeIdentity) Middleware(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if f.actor == nil {
+			http.Error(w, "sin token", http.StatusUnauthorized)
+			return
+		}
+		next.ServeHTTP(w, r)
+	})
+}
+
+func (f fakeIdentity) RequireScope(scope string) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			if !slices.Contains(f.scopes, scope) {
+				http.Error(w, "falta "+scope, http.StatusForbidden)
+				return
+			}
+			next.ServeHTTP(w, r)
+		})
+	}
+}
+
+func (f fakeIdentity) Actor(*http.Request) pedidos.Actor { return *f.actor }
+
+var ana = &pedidos.Actor{ID: memoria.AnaID, Permissions: []pedidos.Permission{pedidos.PermBuy}}
+
+// do monta la API con esa identidad y hace una petición.
+func do(id fakeIdentity, method, path, body string) *httptest.ResponseRecorder {
+	mux := http.NewServeMux()
+	rest.Register(mux, pedidos.NewService(memoria.NewWithSamples()), id)
+	rec := httptest.NewRecorder()
+	mux.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body)))
+	return rec
+}
+
+func TestCodigosDeEstado(t *testing.T) {
+	tests := []struct {
+		name         string
+		id           fakeIdentity
+		method, path string
+		body         string
+		want         int
+	}{
+		{"sin identidad", fakeIdentity{}, "GET", "/pedidos", "", 401},
+		{"mis pedidos", fakeIdentity{actor: ana}, "GET", "/pedidos", "", 200},
+		{"pedido de otro → 404", fakeIdentity{actor: ana}, "GET", "/pedidos/1003", "", 404},
+		{"id que no es número", fakeIdentity{actor: ana}, "GET", "/pedidos/abc", "", 400},
+		{"crear sin scope", fakeIdentity{actor: ana}, "POST", "/pedidos", `{"producto":"taza","cantidad":1}`, 403},
+		{"crear con scope", fakeIdentity{actor: ana, scopes: []string{"pedidos:escribir"}}, "POST", "/pedidos", `{"producto":"taza","cantidad":1}`, 201},
+		{"crear con datos inválidos", fakeIdentity{actor: ana, scopes: []string{"pedidos:escribir"}}, "POST", "/pedidos", `{"producto":"yate","cantidad":1}`, 400},
+		{"admin sin permiso de dominio", fakeIdentity{actor: ana}, "GET", "/admin/pedidos", "", 403},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if rec := do(tt.id, tt.method, tt.path, tt.body); rec.Code != tt.want {
+				t.Errorf("%s %s = %d, want %d (%s)", tt.method, tt.path, rec.Code, tt.want, rec.Body)
+			}
+		})
+	}
+}
+
+// El formato JSON es un contrato con tienda-web y facturacion: si cambia un
+// nombre de campo, este test avisa.
+func TestFormatoJSON(t *testing.T) {
+	rec := do(fakeIdentity{actor: ana}, "GET", "/pedidos/1001", "")
+	var got map[string]any
+	if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+		t.Fatal(err)
+	}
+	for _, k := range []string{"id", "owner", "items", "total", "status", "created_at"} {
+		if _, ok := got[k]; !ok {
+			t.Errorf("falta el campo %q en %s", k, rec.Body)
+		}
+	}
+}
internal/apiauth/apiauth_test.go nuevo · +148 −0
@@ -0,0 +1,148 @@
+package apiauth_test
+
+import (
+	"context"
+	"crypto/rand"
+	"crypto/rsa"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"slices"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/go-jose/go-jose/v4"
+	"github.com/go-jose/go-jose/v4/jwt"
+
+	"tienda/internal/apiauth"
+)
+
+// fakeIssuer es un «Keycloak» mínimo para tests: publica el documento de
+// descubrimiento y el JWKS, y firma tokens con su clave. Así se prueba la
+// validación de verdad (firma, iss, aud, exp, typ) sin levantar Keycloak.
+type fakeIssuer struct {
+	srv *httptest.Server
+	key *rsa.PrivateKey
+}
+
+func newFakeIssuer(t *testing.T) *fakeIssuer {
+	t.Helper()
+	key, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		t.Fatal(err)
+	}
+	f := &fakeIssuer{key: key}
+	mux := http.NewServeMux()
+	mux.HandleFunc("GET /.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
+		json.NewEncoder(w).Encode(map[string]any{
+			"issuer":                                f.srv.URL,
+			"jwks_uri":                              f.srv.URL + "/certs",
+			"authorization_endpoint":                f.srv.URL + "/auth",
+			"token_endpoint":                        f.srv.URL + "/token",
+			"id_token_signing_alg_values_supported": []string{"RS256"},
+		})
+	})
+	mux.HandleFunc("GET /certs", func(w http.ResponseWriter, r *http.Request) {
+		json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{
+			{Key: &key.PublicKey, KeyID: "clave-1", Algorithm: "RS256", Use: "sig"},
+		}})
+	})
+	f.srv = httptest.NewServer(mux)
+	t.Cleanup(f.srv.Close)
+	return f
+}
+
+// claims de un access token típico de Keycloak para ana en api-pedidos.
+func (f *fakeIssuer) claims() map[string]any {
+	return map[string]any{
+		"iss": f.srv.URL, "sub": "id-de-ana", "aud": []string{"api-pedidos", "account"},
+		"exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(),
+		"typ": "Bearer", "azp": "tienda-web", "preferred_username": "ana",
+		"scope":           "openid profile pedidos:escribir",
+		"realm_access":    map[string]any{"roles": []string{"cliente"}},
+		"resource_access": map[string]any{"api-pedidos": map[string]any{"roles": []string{"facturar"}}},
+	}
+}
+
+// sign firma los claims con key (la del issuer, o otra para simular un ataque).
+func sign(t *testing.T, key *rsa.PrivateKey, claims map[string]any) string {
+	t.Helper()
+	signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key},
+		(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", "clave-1"))
+	if err != nil {
+		t.Fatal(err)
+	}
+	raw, err := jwt.Signed(signer).Claims(claims).Serialize()
+	if err != nil {
+		t.Fatal(err)
+	}
+	return raw
+}
+
+func TestMiddleware(t *testing.T) {
+	f := newFakeIssuer(t)
+	v, err := apiauth.NewVerifier(context.Background(), f.srv.URL, "api-pedidos")
+	if err != nil {
+		t.Fatal(err)
+	}
+	var got *apiauth.Principal
+	h := v.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		got = apiauth.FromContext(r.Context())
+	}))
+
+	otherKey, _ := rsa.GenerateKey(rand.Reader, 2048)
+	with := func(k string, val any) map[string]any { c := f.claims(); c[k] = val; return c }
+
+	tests := []struct {
+		name     string
+		token    string // "" = sin cabecera Authorization
+		want     int
+		wantDesc string
+	}{
+		{"token válido", sign(t, f.key, f.claims()), 200, ""},
+		{"sin token", "", 401, "falta la cabecera"},
+		{"caducado", sign(t, f.key, with("exp", time.Now().Add(-time.Minute).Unix())), 401, "caducado"},
+		{"para otra API", sign(t, f.key, with("aud", "otra-api")), 401, "inválido"},
+		{"de otro issuer", sign(t, f.key, with("iss", "http://otro")), 401, "inválido"},
+		{"un ID token, no un access token", sign(t, f.key, with("typ", "ID")), 401, "inválido"},
+		{"firmado con otra clave", sign(t, otherKey, f.claims()), 401, "inválido"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			got = nil
+			req := httptest.NewRequest("GET", "/pedidos", nil)
+			if tt.token != "" {
+				req.Header.Set("Authorization", "Bearer "+tt.token)
+			}
+			rec := httptest.NewRecorder()
+			h.ServeHTTP(rec, req)
+			if rec.Code != tt.want {
+				t.Fatalf("código = %d, want %d (%s)", rec.Code, tt.want, rec.Body)
+			}
+			if tt.want == 401 {
+				if !strings.HasPrefix(rec.Header().Get("WWW-Authenticate"), "Bearer") {
+					t.Errorf("falta WWW-Authenticate: Bearer (RFC 6750)")
+				}
+				if !strings.Contains(rec.Body.String(), tt.wantDesc) {
+					t.Errorf("cuerpo %s no menciona %q", rec.Body, tt.wantDesc)
+				}
+			}
+		})
+	}
+
+	// Con el token válido, el Principal refleja los claims.
+	if got == nil {
+		h.ServeHTTP(httptest.NewRecorder(), withBearer(sign(t, f.key, f.claims())))
+	}
+	if got.Subject != "id-de-ana" || got.Username != "ana" || !got.HasRole("cliente") ||
+		!got.HasScope("pedidos:escribir") || !slices.Contains(got.APIRoles, "facturar") {
+		t.Errorf("Principal inesperado: %+v", got)
+	}
+}
+
+func withBearer(tok string) *http.Request {
+	r := httptest.NewRequest("GET", "/", nil)
+	r.Header.Set("Authorization", "Bearer "+tok)
+	return r
+}
internal/integracion/keycloak_test.go nuevo · +111 −0
@@ -0,0 +1,111 @@
+//go:build integracion
+
+// Package integracion prueba api-pedidos contra un Keycloak de verdad, en un
+// contenedor que el propio test arranca (testcontainers-go) con el realm del
+// paso. Necesita Docker y tarda un minuto, por eso va aparte:
+//
+//	go test -tags integracion ./internal/integracion
+package integracion
+
+import (
+	"context"
+	"encoding/json"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/testcontainers/testcontainers-go"
+	"github.com/testcontainers/testcontainers-go/wait"
+
+	"tienda/internal/adaptadores/keycloak"
+	"tienda/internal/adaptadores/memoria"
+	"tienda/internal/adaptadores/rest"
+	"tienda/internal/apiauth"
+	"tienda/internal/pedidos"
+)
+
+func TestAPIConKeycloakReal(t *testing.T) {
+	ctx := context.Background()
+	kc, err := testcontainers.Run(ctx, "quay.io/keycloak/keycloak:26.8.0",
+		testcontainers.WithCmd("start-dev", "--import-realm"),
+		testcontainers.WithEnv(map[string]string{
+			"KC_BOOTSTRAP_ADMIN_USERNAME": "admin",
+			"KC_BOOTSTRAP_ADMIN_PASSWORD": "admin",
+		}),
+		testcontainers.WithFiles(testcontainers.ContainerFile{
+			HostFilePath:      "../../infra/realm/tienda-realm.json",
+			ContainerFilePath: "/opt/keycloak/data/import/tienda-realm.json",
+			FileMode:          0o644,
+		}),
+		testcontainers.WithExposedPorts("8080/tcp"),
+		testcontainers.WithWaitStrategyAndDeadline(3*time.Minute,
+			wait.ForHTTP("/realms/tienda").WithPort("8080/tcp")),
+	)
+	testcontainers.CleanupContainer(t, kc)
+	if err != nil {
+		t.Fatal(err)
+	}
+	endpoint, err := kc.PortEndpoint(ctx, "8080/tcp", "http")
+	if err != nil {
+		t.Fatal(err)
+	}
+	issuer := endpoint + "/realms/tienda"
+
+	// api-pedidos completa, con sus adaptadores reales, en un servidor de test.
+	v, err := apiauth.NewVerifier(ctx, issuer, "api-pedidos")
+	if err != nil {
+		t.Fatal(err)
+	}
+	mux := http.NewServeMux()
+	rest.Register(mux, pedidos.NewService(memoria.NewWithSamples()), keycloak.Identity{Verifier: v})
+	api := httptest.NewServer(mux)
+	defer api.Close()
+
+	// facturacion pide un token (Client Credentials) y lista lo entregado.
+	tok := clientCredentials(t, issuer, "facturacion", "facturacion-secret")
+	if code, body := get(t, api.URL+"/facturacion/pedidos", tok); code != 200 || !strings.Contains(body, `"id":1003`) {
+		t.Errorf("facturacion: %d %s", code, body)
+	}
+	// El token de admin-tool es válido, pero no es para api-pedidos (aud).
+	other := clientCredentials(t, issuer, "admin-tool", "admin-tool-secret")
+	if code, _ := get(t, api.URL+"/facturacion/pedidos", other); code != 401 {
+		t.Errorf("token de otra audiencia: %d, want 401", code)
+	}
+}
+
+func clientCredentials(t *testing.T, issuer, id, secret string) string {
+	t.Helper()
+	req, _ := http.NewRequest("POST", issuer+"/protocol/openid-connect/token",
+		strings.NewReader(url.Values{"grant_type": {"client_credentials"}}.Encode()))
+	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+	req.SetBasicAuth(id, secret)
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+	var out struct {
+		AccessToken string `json:"access_token"`
+	}
+	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.AccessToken == "" {
+		t.Fatalf("token de %s: %v (%s)", id, err, resp.Status)
+	}
+	return out.AccessToken
+}
+
+func get(t *testing.T, u, tok string) (int, string) {
+	t.Helper()
+	req, _ := http.NewRequest("GET", u, nil)
+	req.Header.Set("Authorization", "Bearer "+tok)
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+	body, _ := io.ReadAll(resp.Body)
+	return resp.StatusCode, string(body)
+}
internal/pedidos/servicio_test.go nuevo · +121 −0
@@ -0,0 +1,121 @@
+package pedidos_test
+
+import (
+	"context"
+	"errors"
+	"testing"
+
+	"tienda/internal/adaptadores/memoria"
+	"tienda/internal/pedidos"
+)
+
+// Actores de prueba: el dominio no sabe de tokens, así que basta con crearlos.
+var (
+	ana      = pedidos.Actor{ID: memoria.AnaID, Name: "ana", Permissions: []pedidos.Permission{pedidos.PermBuy}}
+	carlos   = pedidos.Actor{ID: memoria.CarlosID, Name: "carlos", Permissions: []pedidos.Permission{pedidos.PermBuy, pedidos.PermManage}}
+	factura  = pedidos.Actor{ID: "service-account-facturacion", Permissions: []pedidos.Permission{pedidos.PermInvoice}}
+	anonimo  = pedidos.Actor{ID: "x"}
+	pedidoDe = map[string]int{"ana": 1001, "carlos": 1003} // de memoria.NewWithSamples
+)
+
+func newService() *pedidos.Service { return pedidos.NewService(memoria.NewWithSamples()) }
+
+func TestMineSoloDevuelveLosPropios(t *testing.T) {
+	got, err := newService().Mine(context.Background(), ana)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(got) != 2 {
+		t.Fatalf("ana tiene 2 pedidos de ejemplo, got %d", len(got))
+	}
+	for _, o := range got {
+		if o.Owner != ana.ID {
+			t.Errorf("pedido #%d es de %s, no de ana", o.ID, o.Owner)
+		}
+	}
+}
+
+func TestGet(t *testing.T) {
+	tests := []struct {
+		name    string
+		actor   pedidos.Actor
+		id      int
+		wantErr error
+	}{
+		{"su propio pedido", ana, pedidoDe["ana"], nil},
+		{"el de otro cliente parece no existir", ana, pedidoDe["carlos"], pedidos.ErrNotFound},
+		{"quien gestiona ve cualquiera", carlos, pedidoDe["ana"], nil},
+		{"un número que no existe", carlos, 9999, pedidos.ErrNotFound},
+		{"sin permisos", anonimo, pedidoDe["ana"], pedidos.ErrForbidden},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			_, err := newService().Get(context.Background(), tt.actor, tt.id)
+			if !errors.Is(err, tt.wantErr) {
+				t.Errorf("err = %v, want %v", err, tt.wantErr)
+			}
+		})
+	}
+}
+
+func TestCreateTomaPrecioDelCatalogoYDuenoDelActor(t *testing.T) {
+	o, err := newService().Create(context.Background(), ana, "taza", 3)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if o.Owner != ana.ID || o.Total != 28.5 || o.Status != "Pendiente" || o.ID == 0 {
+		t.Errorf("pedido inesperado: %+v", o)
+	}
+}
+
+func TestCreateRechazaDatosInvalidos(t *testing.T) {
+	for _, tc := range []struct {
+		producto string
+		cantidad int
+	}{{"taza", 0}, {"taza", 11}, {"yate", 1}} {
+		_, err := newService().Create(context.Background(), ana, tc.producto, tc.cantidad)
+		if !errors.Is(err, pedidos.ErrInvalid) {
+			t.Errorf("Create(%q, %d): err = %v, want ErrInvalid", tc.producto, tc.cantidad, err)
+		}
+	}
+}
+
+func TestPermisos(t *testing.T) {
+	ctx := context.Background()
+	s := newService()
+	checks := []struct {
+		name string
+		err  error
+	}{
+		{"un cliente no ve todos los pedidos", second(s.All(ctx, ana))},
+		{"un cliente no cambia estados", second(s.SetStatus(ctx, ana, 1001, "Enviado"))},
+		{"un cliente no lista para facturar", second(s.ToInvoice(ctx, ana, "Entregado"))},
+		{"facturacion no compra", second(s.Create(ctx, factura, "taza", 1))},
+	}
+	for _, c := range checks {
+		if !errors.Is(c.err, pedidos.ErrForbidden) {
+			t.Errorf("%s: err = %v, want ErrForbidden", c.name, c.err)
+		}
+	}
+}
+
+func TestSetStatus(t *testing.T) {
+	ctx := context.Background()
+	s := newService()
+	if _, err := s.SetStatus(ctx, carlos, 1002, "Volando"); !errors.Is(err, pedidos.ErrInvalid) {
+		t.Errorf("estado inventado: err = %v, want ErrInvalid", err)
+	}
+	if _, err := s.SetStatus(ctx, carlos, 1002, "Enviado"); err != nil {
+		t.Fatal(err)
+	}
+	got, err := s.ToInvoice(ctx, factura, "Enviado")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if len(got) != 2 { // 1001 ya estaba Enviado
+		t.Errorf("pedidos enviados = %d, want 2", len(got))
+	}
+}
+
+// second devuelve el error de una llamada que devuelve (valor, error).
+func second[T any](_ T, err error) error { return err }

← Volver a la lección 14