| new file mode 100644 |
| index 0000000..2e9bd62 |
| --- /dev/null |
| +++ b/internal/docs/identity_test.go |
| @@ -0,0 +1,285 @@ |
| +package docs |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/rastrillo/sessions" |
| +) |
| + |
| +func identityRequest(method, subject, authMethod string) *http.Request { |
| + return sessions.WithSession(httptest.NewRequest(method, "https://docs.example/", nil), sessions.Session{Subject: subject, Method: authMethod}) |
| +} |
| + |
| +func TestHomeIdentityNeverCreatesPersonOrSamples(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "1") |
| + store := app.Store() |
| + h := &handlers{store: store} |
| + for _, subject := range []string{"opaque_subject", "looks-like-local@example.com"} { |
| + r := identityRequest("GET", subject, homeAssertionMethod) |
| + if _, err := h.owner(r); err != errIdentityRefused { |
| + t.Fatal("unconfigured Home identity reached legacy owner", err) |
| + } |
| + if _, err := h.actor(r); err != errIdentityRefused { |
| + t.Fatal("unconfigured Home identity reached legacy actor", err) |
| + } |
| + if h.member(r) { |
| + t.Fatal("unconfigured Home identity gained membership") |
| + } |
| + for _, id := range []int64{0, 999999} { |
| + v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(ctx context.Context, issuer, got string) (int64, error) { |
| + if issuer != "https://id.example" || got != subject { |
| + t.Fatal("issuer/subject changed") |
| + } |
| + return id, nil |
| + }} |
| + reached := false |
| + rec := httptest.NewRecorder() |
| + v.middleware("/signin")(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })).ServeHTTP(rec, r) |
| + if reached || rec.Code != 303 { |
| + t.Fatal("unknown Home identity reached handler") |
| + } |
| + } |
| + } |
| + var people, documents int64 |
| + if err := store.db.Model(&Person{}).Count(&people).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := store.db.Model(&Document{}).Count(&documents).Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + if people != 0 || documents != 0 { |
| + t.Fatalf("Home path created people=%d documents=%d", people, documents) |
| + } |
| +} |
| + |
| +func TestLinkedHomeIdentityPreservesOwnersAndAddressGrants(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + store := app.Store() |
| + ctx := t.Context() |
| + alice, err := store.EnsurePerson(ctx, "alice@example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + bob, err := store.EnsurePerson(ctx, "bob@example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + own, err := store.Create(ctx, alice, "Alice private") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + shared, err := store.Create(ctx, bob, "Bob shared") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + hidden, err := store.Create(ctx, bob, "Bob private") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = store.Grant(ctx, bob, shared.Sqid, "alice@example.com", RoleWriter); err != nil { |
| + t.Fatal(err) |
| + } |
| + v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(ctx context.Context, issuer, subject string) (int64, error) { |
| + if issuer != "https://id.example" || subject != "opaque_alice" { |
| + return 0, nil |
| + } |
| + return alice, nil |
| + }} |
| + memberAddresses := []string{} |
| + h := &handlers{store: store, isMember: func(address string) bool { |
| + memberAddresses = append(memberAddresses, address) |
| + return address == "alice@example.com" |
| + }} |
| + reached := false |
| + end := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + reached = true |
| + a, err := h.actor(r) |
| + if err != nil || a.PersonID != alice || a.Subject != "alice@example.com" { |
| + t.Fatal("linked actor changed", a, err) |
| + } |
| + id, err := h.owner(r) |
| + if err != nil || id != alice { |
| + t.Fatal("linked owner changed", err) |
| + } |
| + if !h.member(r) { |
| + t.Fatal("member did not use verified local address") |
| + } |
| + for _, d := range []*Document{own, shared} { |
| + got, err := store.GetFor(ctx, a, d.Sqid) |
| + if err != nil || got.ID != d.ID || !store.CanWrite(ctx, a, d.ID) { |
| + t.Fatal("existing access lost", err) |
| + } |
| + } |
| + if _, err := store.GetFor(ctx, a, hidden.Sqid); err == nil { |
| + t.Fatal("link widened access") |
| + } |
| + if id, ok := currentIdentity(r); !ok || id.Session.Subject != "opaque_alice" { |
| + t.Fatal("opaque identity not kept separate") |
| + } |
| + }) |
| + rec := httptest.NewRecorder() |
| + v.middleware("/signin")(end).ServeHTTP(rec, identityRequest("GET", "opaque_alice", homeAssertionMethod)) |
| + if !reached || len(memberAddresses) != 1 { |
| + t.Fatal("adapter not used") |
| + } |
| + var after Document |
| + if err = store.db.First(&after, own.ID).Error; err != nil || after.OwnerID != alice { |
| + t.Fatal("ownership rewritten") |
| + } |
| + grants, err := store.ListGrants(ctx, bob, shared.Sqid) |
| + if err != nil || len(grants) != 1 || grants[0].Subject != "alice@example.com" { |
| + t.Fatal("grant rewritten") |
| + } |
| + // Resolve the current local address on every request, not a saved copy. |
| + if err = store.db.Model(&Person{}).Where("id=?", alice).Update("email", "renamed@example.com").Error; err != nil { |
| + t.Fatal(err) |
| + } |
| + id, err := v.resolve(identityRequest("GET", "opaque_alice", homeAssertionMethod)) |
| + if err != nil || id.Actor.Subject != "renamed@example.com" { |
| + t.Fatal("stale local address") |
| + } |
| +} |
| + |
| +func TestHomeAdmissionRequiresAdapterAndCurrentAddress(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + store := app.Store() |
| + person, err := store.EnsurePerson(t.Context(), "local@example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + list, err := resolveAllowlist(Config{AllowedAddresses: "local@example.com"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + v := identityResolver{store: store, issuer: "https://id.example", linkedPerson: func(context.Context, string, string) (int64, error) { return person, nil }} |
| + reached := false |
| + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true }) |
| + for _, method := range []string{"GET", "POST"} { |
| + reached = false |
| + rec := httptest.NewRecorder() |
| + requireAdmission(list, "/signin")(next).ServeHTTP(rec, identityRequest(method, "local@example.com", homeAssertionMethod)) |
| + if reached || (method == "GET" && rec.Code != 303) || (method == "POST" && rec.Code != 403) { |
| + t.Fatal("opaque address bypassed adapter") |
| + } |
| + } |
| + rec := httptest.NewRecorder() |
| + v.middleware("/signin")(requireAdmission(list, "/signin")(next)).ServeHTTP(rec, identityRequest("GET", "opaque", homeAssertionMethod)) |
| + if !reached { |
| + t.Fatal("resolved local address not admitted") |
| + } |
| + reached = false |
| + list, err = resolveAllowlist(Config{AllowedAddresses: "someone-else@example.com"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rec = httptest.NewRecorder() |
| + v.middleware("/signin")(requireAdmission(list, "/signin")(next)).ServeHTTP(rec, identityRequest("POST", "opaque", homeAssertionMethod)) |
| + if reached || rec.Code != 403 { |
| + t.Fatal("link bypassed changed admission") |
| + } |
| + v.linkedPerson = func(context.Context, string, string) (int64, error) { return 0, errors.New("fixture storage failure") } |
| + rec = httptest.NewRecorder() |
| + v.middleware("/signin")(next).ServeHTTP(rec, identityRequest("POST", "opaque", homeAssertionMethod)) |
| + if reached || rec.Code != 503 { |
| + t.Fatal("storage failure became admission or permanent write refusal") |
| + } |
| +} |
| + |
| +func TestLocalFreshnessUsesActualMethodAndVerificationTime(t *testing.T) { |
| + now := time.Unix(1700000000, 0) |
| + for _, tc := range []struct { |
| + name, method string |
| + auth, created time.Time |
| + want bool |
| + }{ |
| + {"magic original", "magiclink", now.Add(-time.Minute), now, true}, |
| + {"magic persisted creation", "magiclink", time.Time{}, now.Add(-time.Minute), true}, |
| + {"boundary", "magiclink", now.Add(-5 * time.Minute), now, true}, |
| + {"old verification fresh remint", "magiclink", now.Add(-301 * time.Second), now, false}, |
| + {"old persisted creation", "magiclink", time.Time{}, now.Add(-301 * time.Second), false}, |
| + {"future verification", "magiclink", now.Add(time.Second), now, false}, |
| + {"future creation", "magiclink", time.Time{}, now.Add(time.Second), false}, |
| + {"missing times", "magiclink", time.Time{}, time.Time{}, false}, |
| + {"keymail actual", "keymail", now.Add(-time.Minute), now, true}, |
| + {"keymail no original", "keymail", time.Time{}, now, false}, |
| + {"keymail stale", "keymail", now.Add(-301 * time.Second), now, false}, |
| + {"keymail future", "keymail", now.Add(time.Second), now, false}, |
| + {"home original", "home-assertion", now, now, false}, |
| + {"home remint", "home-assertion", time.Time{}, now, false}, |
| + {"other original", "password", now, now, false}, |
| + {"unknown remint", "", time.Time{}, now, false}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + got, ok := freshLocalAuthentication(sessions.Session{Subject: "local@example.com", Method: tc.method, AuthTime: tc.auth, At: tc.created}, now) |
| + if ok != tc.want { |
| + t.Fatal("wrong local freshness decision") |
| + } |
| + expected := tc.auth |
| + if expected.IsZero() { |
| + expected = tc.created |
| + } |
| + if ok && !got.Equal(expected) { |
| + t.Fatal("verification time rewritten") |
| + } |
| + if !ok && !got.IsZero() { |
| + t.Fatal("refused proof returned freshness") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestLocalMagicLinkUsesPersistedVerificationAndRotation(t *testing.T) { |
| + app := newSampleDocumentsInternalTestApp(t, "0") |
| + reader, err := sessions.New(sessions.Config{DB: app.db.Writer(), Origin: app.cfg.Origin}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + first := app.TestRequest(t, "local@example.com", "GET", "/", nil) |
| + session, ok := reader.From(first) |
| + if !ok || session.Method != "magiclink" || !session.AuthTime.IsZero() || session.At.IsZero() { |
| + t.Fatal("real local magic-link session did not match fallback contract") |
| + } |
| + verified, ok := freshLocalAuthentication(session, time.Now()) |
| + if !ok || !verified.Equal(session.At) { |
| + t.Fatal("actual completed magic link not fresh") |
| + } |
| + oldCookie, err := first.Cookie(reader.CookieName()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + // Complete a second real local email token while presenting the first session. |
| + link, err := app.TestSignIn(t, "local@example.com") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + verify := httptest.NewRequest("GET", link, nil) |
| + verify.AddCookie(oldCookie) |
| + rec := httptest.NewRecorder() |
| + app.Handler().ServeHTTP(rec, verify) |
| + if rec.Code != 303 { |
| + t.Fatal("local reverification failed", rec.Code) |
| + } |
| + second := httptest.NewRequest("GET", "/", nil) |
| + for _, c := range rec.Result().Cookies() { |
| + second.AddCookie(c) |
| + } |
| + if _, ok = reader.From(first); ok { |
| + t.Fatal("reverification left old session usable") |
| + } |
| + next, ok := reader.From(second) |
| + if !ok { |
| + t.Fatal("rotated session missing") |
| + } |
| + if _, ok = freshLocalAuthentication(next, time.Now()); !ok { |
| + t.Fatal("rotated real magic link not fresh") |
| + } |
| + newCookie, err := second.Cookie(reader.CookieName()) |
| + if err != nil || newCookie.Value == oldCookie.Value { |
| + t.Fatal("actual browser binding did not rotate") |
| + } |
| +} |