package user import ( "encoding/json" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/bouncer" ) // TestBackendLinkAuthTime pins the account link proof of plan 08 (C-CR-02, // P-34): confirming a link needs a sign-in at or after the request, read // from the session's auth_time, which refresh copies and never renews. A // session signed in before the request stays stale through a refresh, a // sign-in after the request confirms (also after a refresh), a token // without auth_time is stale, and a session token of another user than the // authenticated one is 401. Postgres only: under -short nothing runs. func TestBackendLinkAuthTime(t *testing.T) { if testing.Short() { return } env := newAdminEnv(t) login := func(t *testing.T, user *models.User) string { t.Helper() status, token := env.userLogin(t, user.Email, "correct-horse") if status != http.StatusOK || token == "" { t.Fatalf("login = %d", status) } return token } refresh := func(t *testing.T, token string) string { t.Helper() rec := env.refresh(t, token) var body struct { Token string `json:"token"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); rec.Code != http.StatusOK || err != nil || body.Token == "" { t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String()) } return body.Token } // signInEarlier waits past the second of the sign-in, so a request // created next is strictly later (auth_time has whole seconds). signInEarlier := func() { time.Sleep(1100 * time.Millisecond) } expect := func(t *testing.T, token, code string, status int, contains string) { t.Helper() got, body := env.confirmLink(t, token, code) if got != status || !strings.Contains(body, contains) { t.Fatalf("confirm = %d %s, want %d with %q", got, body, status, contains) } } t.Run("login, request, refresh, confirm is stale", func(t *testing.T) { user := env.seedUser(t, "at-refresh", nil) session := login(t, user) signInEarlier() code := env.startLink(t, env.admin(t)) expect(t, refresh(t, session), code, http.StatusConflict, `"stale_session"`) }) t.Run("request, login, confirm links", func(t *testing.T) { user := env.seedUser(t, "at-after", nil) code := env.startLink(t, env.admin(t)) expect(t, login(t, user), code, http.StatusOK, `"backend_user"`) }) t.Run("login, request, login again, confirm links", func(t *testing.T) { user := env.seedUser(t, "at-again", nil) first := login(t, user) signInEarlier() code := env.startLink(t, env.admin(t)) expect(t, first, code, http.StatusConflict, `"stale_session"`) expect(t, login(t, user), code, http.StatusOK, `"backend_user"`) }) t.Run("a hand-made token without auth_time is stale", func(t *testing.T) { user := env.seedUser(t, "at-legacy", nil) code := env.startLink(t, env.admin(t)) legacy := craftToken(t, strconv.FormatUint(uint64(user.ID), 10), "at-legacy-"+adminStamp(), time.Now(), time.Now().Add(time.Hour)) expect(t, legacy, code, http.StatusConflict, `"stale_session"`) }) t.Run("a refreshed session of a post-request sign-in keeps working", func(t *testing.T) { user := env.seedUser(t, "at-carried", nil) code := env.startLink(t, env.admin(t)) session := login(t, user) expect(t, refresh(t, refresh(t, session)), code, http.StatusOK, `"backend_user"`) }) t.Run("another user's session token is 401", func(t *testing.T) { user := env.seedUser(t, "at-owner", nil) other := env.seedUser(t, "at-other", nil) code := env.startLink(t, env.admin(t)) // jwt.auth authenticated user, but the session token the handler // reads again for its sign-in time belongs to other. req := httptest.NewRequest(http.MethodPost, "/_user/api/v1/backend-link/confirm", strings.NewReader(`{"code":"`+code+`"}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+login(t, other)) req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: user.ID})) rec := httptest.NewRecorder() controllers.BackendLinkConfirm(env.app)(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("mismatched session token = %d %s", rec.Code, rec.Body.String()) } // The code is still usable by its rightful user. expect(t, login(t, user), code, http.StatusOK, `"backend_user"`) }) }