package identity import ( "context" "encoding/json" "errors" "os" "reflect" "testing" "time" ) type authorizationFixture struct { RequiredClientID string `json:"requiredClientId"` SessionSecret string `json:"sessionSecret"` NowUnix int64 `json:"nowUnix"` Cases []struct { Name string `json:"name"` Session Session `json:"session"` Snapshot *AuthorizationSnapshot `json:"snapshot"` Expected struct { Outcome string `json:"outcome"` Reason RejectionReason `json:"reason"` LoaderCalls int `json:"loaderCalls"` Session Session `json:"session"` } `json:"expected"` } `json:"cases"` } type recordingAuthorizationLoader struct { snapshot AuthorizationSnapshot found bool err error ids []string } func (loader *recordingAuthorizationLoader) FindAuthorizationSnapshot(_ context.Context, id string) (AuthorizationSnapshot, bool, error) { loader.ids = append(loader.ids, id) return loader.snapshot, loader.found, loader.err } func TestResolverDrivesPlatformAuthorizationContract(t *testing.T) { fixture := loadAuthorizationFixture(t) if len(fixture.Cases) != 14 { t.Fatalf("authorization fixture cases = %d, want 14", len(fixture.Cases)) } for _, testCase := range fixture.Cases { t.Run(testCase.Name, func(t *testing.T) { cookie, err := json.Marshal(testCase.Session) if err != nil { t.Fatalf("marshal session fixture: %v", err) } signed, err := Sign(cookie, fixture.SessionSecret) if err != nil { t.Fatalf("sign session fixture: %v", err) } loader := &recordingAuthorizationLoader{} if testCase.Snapshot != nil { loader.snapshot = *testCase.Snapshot loader.found = true } resolver := NewResolver(loader, fixture.SessionSecret, fixture.RequiredClientID, func() time.Time { return time.Unix(fixture.NowUnix, 0) }) got, resolveErr := resolver.Resolve(context.Background(), signed) if len(loader.ids) != testCase.Expected.LoaderCalls { t.Fatalf("loader calls = %d, want %d", len(loader.ids), testCase.Expected.LoaderCalls) } if len(loader.ids) == 1 && loader.ids[0] != testCase.Session.User.ID { t.Fatalf("loader ID = %q, want %q", loader.ids[0], testCase.Session.User.ID) } switch testCase.Expected.Outcome { case "authenticated": if resolveErr != nil { t.Fatalf("Resolve() error = %v", resolveErr) } if !reflect.DeepEqual(got, testCase.Expected.Session) { t.Errorf("resolved session mismatch\n got: %#v\nwant: %#v", got, testCase.Expected.Session) } case "unauthenticated": var rejection *UnauthenticatedError if !errors.As(resolveErr, &rejection) { t.Fatalf("Resolve() error = %v, want typed unauthenticated rejection", resolveErr) } if !errors.Is(resolveErr, ErrUnauthenticated) { t.Errorf("Resolve() must collapse to ErrUnauthenticated") } if rejection.Reason != testCase.Expected.Reason { t.Errorf("rejection reason = %q, want %q", rejection.Reason, testCase.Expected.Reason) } default: t.Fatalf("unsupported fixture outcome %q", testCase.Expected.Outcome) } }) } } func TestResolverPropagatesLoaderError(t *testing.T) { fixture := loadAuthorizationFixture(t) databaseErr := errors.New("database unavailable") loader := &recordingAuthorizationLoader{err: databaseErr} resolver := NewResolver(loader, fixture.SessionSecret, fixture.RequiredClientID, func() time.Time { return time.Unix(fixture.NowUnix, 0) }) signed := signFixtureSession(t, fixture.Cases[0].Session, fixture.SessionSecret) _, err := resolver.Resolve(context.Background(), signed) if !errors.Is(err, databaseErr) { t.Fatalf("Resolve() error = %v, want database error", err) } if errors.Is(err, ErrUnauthenticated) { t.Fatal("database error must not collapse to unauthenticated") } if !reflect.DeepEqual(loader.ids, []string{fixture.Cases[0].Session.User.ID}) { t.Fatalf("loader IDs = %#v", loader.ids) } } func TestResolverRejectsInvalidCookieWithoutLoadingAuthorization(t *testing.T) { fixture := loadAuthorizationFixture(t) loader := &recordingAuthorizationLoader{} resolver := NewResolver(loader, fixture.SessionSecret, fixture.RequiredClientID, func() time.Time { return time.Unix(fixture.NowUnix, 0) }) _, err := resolver.Resolve(context.Background(), "not-a-signed-session") var rejection *UnauthenticatedError if !errors.As(err, &rejection) || rejection.Reason != RejectionInvalidSession { t.Fatalf("Resolve() error = %v, want invalid-session rejection", err) } if len(loader.ids) != 0 { t.Fatalf("loader calls = %d, want 0", len(loader.ids)) } } func TestNewResolverDefaultsRequiredPlatformClient(t *testing.T) { fixture := loadAuthorizationFixture(t) loader := &recordingAuthorizationLoader{} resolver := NewResolver(loader, fixture.SessionSecret, "", func() time.Time { return time.Unix(fixture.NowUnix, 0) }) signed := signFixtureSession(t, fixture.Cases[5].Session, fixture.SessionSecret) _, err := resolver.Resolve(context.Background(), signed) var rejection *UnauthenticatedError if !errors.As(err, &rejection) || rejection.Reason != RejectionClientMismatch { t.Fatalf("Resolve() error = %v, want client mismatch with default platform client", err) } if len(loader.ids) != 0 { t.Fatalf("loader calls = %d, want 0", len(loader.ids)) } } func TestResolverFailsClosedWhenNotConfigured(t *testing.T) { for _, resolver := range []*Resolver{ nil, NewResolver(nil, "secret", "platform", nil), NewResolver(&recordingAuthorizationLoader{}, "", "platform", nil), } { _, err := resolver.Resolve(context.Background(), "cookie") if err == nil || errors.Is(err, ErrUnauthenticated) { t.Fatalf("Resolve() error = %v, want configuration failure", err) } } } func loadAuthorizationFixture(t *testing.T) authorizationFixture { t.Helper() raw, err := os.ReadFile("../../../contracts/auth/platform-session-authorization-v1.json") if err != nil { t.Fatalf("read authorization fixture: %v", err) } var fixture authorizationFixture if err := json.Unmarshal(raw, &fixture); err != nil { t.Fatalf("decode authorization fixture: %v", err) } return fixture } func signFixtureSession(t *testing.T, session Session, secret string) string { t.Helper() raw, err := json.Marshal(session) if err != nil { t.Fatalf("marshal fixture session: %v", err) } signed, err := Sign(raw, secret) if err != nil { t.Fatalf("sign fixture session: %v", err) } return signed }