package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" "github.com/netkingdom/flex-auth/internal/audit" "github.com/netkingdom/flex-auth/internal/callerauth" "github.com/netkingdom/flex-auth/internal/readback" "github.com/netkingdom/flex-auth/internal/schemaguard" "github.com/netkingdom/flex-auth/pkg/api" ) // FLEX-WP-0035: application access readback. const readbackPrincipal = "system:serviceaccount:user-engine:user-engine" func readbackPath(parts ...string) string { return filepath.Join(append([]string{"testdata", "readback"}, parts...)...) } func newTestReadback(t *testing.T) *readback.Service { t.Helper() service, err := buildReadback(context.Background(), []string{readbackPath("demo-app", "registry_snapshot.json"), readbackPath("other-app", "registry_snapshot.json")}, map[string]string{ "demo-app": readbackPath("demo-app", "policy_package.md"), "other-app": readbackPath("other-app", "policy_package.md"), }) if err != nil { t.Fatalf("buildReadback: %v", err) } return service } func readbackAuthenticator(t *testing.T, principal string) *callerauth.Authenticator { t.Helper() authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{ Username: principal, Audiences: []string{"flex-auth"}, }}, "flex-auth", map[string]string{readbackBinding: readbackPrincipal}, nil) if err != nil { t.Fatal(err) } return authenticator } func subjectRequest(id string, mfa bool, systems ...string) readback.Request { return readback.Request{ Tenant: "tenant:platform", Subject: api.SubjectRef{ID: id, Type: "user", Attributes: map[string]any{ "groups": []any{"app-users"}, "assurance": map[string]any{"level": "aal2", "mfa": mfa}, }}, Systems: systems, } } func TestReadbackRowsInRequestOrderAndNeverOmitted(t *testing.T) { service := newTestReadback(t) logPath := filepath.Join(t.TempDir(), "decisions.jsonl") service.SetDecisionLog(audit.NewJSONLDecisionLog(logPath)) response, err := service.Evaluate(context.Background(), subjectRequest("user:alice", true, "other-app", "demo-app", "unknown-app")) if err != nil { t.Fatal(err) } if response.RegistrySnapshotDigest == "" || len(response.Results) != 3 { t.Fatalf("response = %+v", response) } other, demo, unknown := response.Results[0], response.Results[1], response.Results[2] // other-app's rule would allow; the package does not declare the action. if other.System != "other-app" || other.Effect != api.DecisionEffectNotApplicable || other.Reason != readback.ReasonNotOffered || other.DecisionID != "" { t.Fatalf("undeclared row = %+v", other) } if unknown.System != "unknown-app" || unknown.Effect != api.DecisionEffectNotApplicable { t.Fatalf("unknown row = %+v", unknown) } if demo.Effect != api.DecisionEffectAllow || demo.Lifetime == nil || demo.Lifetime.ExpiresAt == "" || demo.PolicyPackageDigest == "" || demo.RevocationVisibility["claims"] != "immediate" { t.Fatalf("allow row = %+v", demo) } // The recorded decision carries purpose readback inside its digests. raw, err := os.ReadFile(logPath) if err != nil { t.Fatal(err) } var recorded api.DecisionEnvelope if err := json.Unmarshal(bytes.TrimSpace(raw), &recorded); err != nil { t.Fatalf("decision log: %v", err) } if recorded.ID != demo.DecisionID || recorded.Binding.Purpose != api.PurposeReadback || recorded.Binding.Action != readback.ApplicationAccess { t.Fatalf("recorded binding = %+v", recorded.Binding) } enforcement := api.CheckRequest{Subject: recorded.Binding.Subject, Action: recorded.Binding.Action, Resource: recorded.Binding.Resource, Tenant: recorded.Binding.Tenant} if api.RequestDigest(enforcement) == recorded.Binding.RequestDigest { t.Fatal("readback request_digest equals the enforcement digest; a PEP could accept it") } } func TestReadbackDeniesWithoutMFA(t *testing.T) { response, err := newTestReadback(t).Evaluate(context.Background(), subjectRequest("user:bob", false, "demo-app")) if err != nil { t.Fatal(err) } if got := response.Results[0]; got.Effect != api.DecisionEffectDeny || got.Lifetime != nil && got.Lifetime.ExpiresAt != "" { t.Fatalf("row = %+v", got) } } func TestReadbackSystemsProjection(t *testing.T) { systems := newTestReadback(t).Systems() byID := map[string]readback.System{} for _, system := range systems.Systems { byID[system.ID] = system } demo, other := byID["demo-app"], byID["other-app"] if !demo.Readback || demo.ClientBinding == nil || demo.ClientBinding.ClientID != "demo-app" || demo.Name != "Demo App" { t.Fatalf("demo-app = %+v", demo) } if other.Readback || other.ClientBinding != nil { t.Fatalf("other-app = %+v", other) } if systems.RegistrySnapshotDigest == "" { t.Fatal("projection has no snapshot digest") } } func TestReadbackRefusesDuplicateSystemAcrossRegistries(t *testing.T) { path := readbackPath("demo-app", "registry_snapshot.json") _, err := buildReadback(context.Background(), []string{path, path}, map[string]string{"demo-app": readbackPath("demo-app", "policy_package.md")}) if err == nil || !strings.Contains(err.Error(), "appears in both") { t.Fatalf("err = %v", err) } } func postReadback(t *testing.T, url string, body string) *http.Response { t.Helper() request, _ := http.NewRequest(http.MethodPost, url+"/v1/readback/application_access", strings.NewReader(body)) request.Header.Set("Authorization", "Bearer token") response, err := http.DefaultClient.Do(request) if err != nil { t.Fatal(err) } response.Body.Close() return response } func TestReadbackHTTPCallerAndRequestShape(t *testing.T) { service := newTestReadback(t) server := httptest.NewServer(newReadbackMux(service, readbackAuthenticator(t, readbackPrincipal))) defer server.Close() valid, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app")) if got := postReadback(t, server.URL, string(valid)).StatusCode; got != http.StatusOK { t.Fatalf("admitted caller status = %d", got) } tooMany := make([]string, readback.MaxSystems+1) for i := range tooMany { tooMany[i] = fmt.Sprintf("app-%d", i) } oversized, _ := json.Marshal(subjectRequest("user:alice", true, tooMany...)) duplicated, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app", "demo-app")) for name, body := range map[string]string{ "smuggled action": `{"subject":{"id":"user:alice"},"systems":["demo-app"],"action":"admin"}`, "smuggled purpose": `{"subject":{"id":"user:alice"},"systems":["demo-app"],"purpose":""}`, "no subject": `{"systems":["demo-app"]}`, "oversized": string(oversized), "duplicated": string(duplicated), } { if got := postReadback(t, server.URL, body).StatusCode; got != http.StatusBadRequest { t.Fatalf("%s: status = %d, want 400", name, got) } } // The readback pin mounts no enforcement route. for _, route := range []string{"/v1/check", "/v1/batch_check"} { response, err := http.Post(server.URL+route, "application/json", strings.NewReader(`{}`)) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusNotFound { t.Fatalf("%s on readback pin = %d, want 404", route, response.StatusCode) } } wrong := httptest.NewServer(newReadbackMux(service, readbackAuthenticator(t, "system:serviceaccount:other:other"))) defer wrong.Close() if got := postReadback(t, wrong.URL, string(valid)).StatusCode; got != http.StatusForbidden { t.Fatalf("unbound caller status = %d, want 403", got) } } // T02 gate: the readback principal gets 403 on an enforcement pin's /v1/check, // and an enforcement pin refuses a purpose-bearing request. func TestReadbackPrincipalCannotCheckOnEnforcementPin(t *testing.T) { engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), "") if err != nil { t.Fatal(err) } authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{ Username: readbackPrincipal, Audiences: []string{"flex-auth"}, }}, "flex-auth", map[string]string{"ops-warden": "system:serviceaccount:ops-warden:ops-warden"}, nil) if err != nil { t.Fatal(err) } server := httptest.NewServer(newServeMuxWithCallerAuth(engine, authenticator)) defer server.Close() body, err := os.ReadFile(opsPath("check_request_allow_adm.json")) if err != nil { t.Fatal(err) } request, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body)) request.Header.Set("Authorization", "Bearer token") response, err := http.DefaultClient.Do(request) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusForbidden { t.Fatalf("readback principal on /v1/check = %d, want 403", response.StatusCode) } var withPurpose map[string]any if err := json.Unmarshal(body, &withPurpose); err != nil { t.Fatal(err) } withPurpose["purpose"] = api.PurposeReadback marked, _ := json.Marshal(withPurpose) open := httptest.NewServer(newServeMux(engine)) defer open.Close() response, err = http.Post(open.URL+"/v1/check", "application/json", bytes.NewReader(marked)) if err != nil { t.Fatal(err) } response.Body.Close() if response.StatusCode != http.StatusBadRequest { t.Fatalf("purpose on /v1/check = %d, want 400", response.StatusCode) } } type failingRecorder struct{} func (failingRecorder) Append(api.DecisionEnvelope) error { return errors.New("disk full") } // FLEX-DEC-2026-018 holds on the readback: an allow whose record fails to // commit is withheld (503, rendered "could not be checked"), never released. func TestReadbackWithholdsUncommittedAllow(t *testing.T) { service := newTestReadback(t) service.SetDecisionLog(failingRecorder{}) server := httptest.NewServer(newReadbackMux(service, callerauth.Disabled())) defer server.Close() allow, _ := json.Marshal(subjectRequest("user:alice", true, "demo-app")) if got := postReadback(t, server.URL, string(allow)).StatusCode; got != http.StatusServiceUnavailable { t.Fatalf("uncommitted allow = %d, want 503", got) } deny, _ := json.Marshal(subjectRequest("user:bob", false, "demo-app")) if got := postReadback(t, server.URL, string(deny)).StatusCode; got != http.StatusOK { t.Fatalf("uncommitted deny = %d, want 200 (a restriction is never withheld)", got) } } // T03 gate: the wire shapes match the published schemas. func TestReadbackShapesMatchSchemas(t *testing.T) { service := newTestReadback(t) request := subjectRequest("user:alice", true, "demo-app", "other-app") response, err := service.Evaluate(context.Background(), request) if err != nil { t.Fatal(err) } for schema, value := range map[string]any{ "readback_request.schema.json": request, "readback_response.schema.json": response, "readback_systems.schema.json": service.Systems(), } { s, err := schemaguard.Load(filepath.Join("..", "..", "schemas", schema)) if err != nil { t.Fatal(err) } raw, _ := json.Marshal(value) var doc any if err := json.Unmarshal(raw, &doc); err != nil { t.Fatal(err) } if problems := s.Validate(doc); len(problems) > 0 { t.Fatalf("%s: %v\n%s", schema, problems, raw) } } } // FLEX-WP-0035-T05: the shipped catalogue and informed-decision lifecycle v2 // answer the readback the way user-engine will ask it. func TestReadbackInformedDecisionCatalogue(t *testing.T) { examples := filepath.Join("..", "..", "examples") service, err := buildReadback(context.Background(), []string{ filepath.Join(examples, "application-readback", "registry_snapshot.json"), filepath.Join(examples, "informed-decision-lifecycle", "registry.json"), }, map[string]string{"informed-decision": filepath.Join(examples, "informed-decision-lifecycle", "policy.md")}) if err != nil { t.Fatal(err) } subject := func(groups ...any) readback.Request { return readback.Request{ Tenant: "tenant:platform", Subject: api.SubjectRef{ID: "human-one", Type: "human", Tenant: "tenant:platform", Attributes: map[string]any{ "issuer": "https://kc.coulomb.social", "roles": []any{}, "tenant_roles": []any{}, "scopes": []any{}, "groups": groups, "assurance": map[string]any{"level": "aal2", "mfa": true, "source": "key-cape", "methods": []any{"pwd", "otp"}, "at": time.Now().Unix()}, }}, Systems: []string{"informed-decision"}, } } for name, tc := range map[string]struct { request readback.Request effect api.DecisionEffect }{ "admin": {subject("net-kingdom-admins"), api.DecisionEffectAllow}, "non-admin": {subject("staff"), api.DecisionEffectDeny}, } { response, err := service.Evaluate(context.Background(), tc.request) if err != nil { t.Fatal(err) } if got := response.Results[0]; got.Effect != tc.effect || got.PolicyPackage != "informed-decision.lifecycle" { t.Fatalf("%s: %+v", name, got) } } systems := service.Systems().Systems if len(systems) != 1 || !systems[0].Readback || systems[0].ClientBinding == nil || systems[0].ClientBinding.ClientID != "informed-decision-approver" { t.Fatalf("catalogue = %+v", systems) } }