accounts and email
This commit is contained in:
@@ -9,8 +9,10 @@ import (
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tim/cairnquire/apps/server/internal/auth"
|
||||
@@ -23,6 +25,17 @@ import (
|
||||
"github.com/tim/cairnquire/apps/server/internal/sync"
|
||||
)
|
||||
|
||||
type testEmailSender struct {
|
||||
body string
|
||||
to []string
|
||||
}
|
||||
|
||||
func (s *testEmailSender) Send(ctx context.Context, to []string, subject, body string) error {
|
||||
s.to = append([]string(nil), to...)
|
||||
s.body = body
|
||||
return nil
|
||||
}
|
||||
|
||||
type apiTestServer struct {
|
||||
handler http.Handler
|
||||
auth *auth.Service
|
||||
@@ -36,6 +49,10 @@ func newAPITestServer(t *testing.T) *apiTestServer {
|
||||
}
|
||||
|
||||
func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer {
|
||||
return newAPITestServerWithSetupAndDevMode(t, setupComplete, false)
|
||||
}
|
||||
|
||||
func newAPITestServerWithSetupAndDevMode(t *testing.T, setupComplete bool, devMode bool) *apiTestServer {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -88,7 +105,8 @@ func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer
|
||||
SourceDir: sourceDir,
|
||||
StoreDir: storeDir,
|
||||
},
|
||||
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
|
||||
Auth: config.AuthConfig{PublicOrigin: "http://localhost:8080"},
|
||||
DevMode: devMode,
|
||||
},
|
||||
Logger: logger,
|
||||
Documents: docService,
|
||||
@@ -112,6 +130,93 @@ func newAPITestServerWithSetup(t *testing.T, setupComplete bool) *apiTestServer
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevModeLogoutSuppressesAutomaticBrowserPrincipal(t *testing.T) {
|
||||
server := newAPITestServerWithSetupAndDevMode(t, true, true)
|
||||
|
||||
devMe := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(devMe, httptest.NewRequest(http.MethodGet, "/api/auth/me", nil))
|
||||
if devMe.Code != http.StatusOK || !bytes.Contains(devMe.Body.Bytes(), []byte(`"method":"dev"`)) {
|
||||
t.Fatalf("dev me response = %d %q, want dev principal", devMe.Code, devMe.Body.String())
|
||||
}
|
||||
|
||||
logout := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(logout, jsonRequest(http.MethodPost, "/api/auth/logout", map[string]any{}))
|
||||
if logout.Code != http.StatusOK {
|
||||
t.Fatalf("logout response = %d %q, want OK", logout.Code, logout.Body.String())
|
||||
}
|
||||
devLogoutCookie := cookieByName(t, logout.Result().Cookies(), devLogoutCookieName)
|
||||
if devLogoutCookie.Value != "1" {
|
||||
t.Fatalf("dev logout cookie = %#v, want opt-out marker", devLogoutCookie)
|
||||
}
|
||||
|
||||
loggedOutMeRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
loggedOutMeRequest.AddCookie(devLogoutCookie)
|
||||
loggedOutMe := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(loggedOutMe, loggedOutMeRequest)
|
||||
if loggedOutMe.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("logged out me response = %d %q, want unauthorized", loggedOutMe.Code, loggedOutMe.Body.String())
|
||||
}
|
||||
|
||||
loginPageRequest := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
loginPageRequest.AddCookie(devLogoutCookie)
|
||||
loginPage := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(loginPage, loginPageRequest)
|
||||
if loginPage.Code != http.StatusOK || !bytes.Contains(loginPage.Body.Bytes(), []byte("data-dev-login")) {
|
||||
t.Fatalf("login page response = %d %q, want dev login button", loginPage.Code, loginPage.Body.String())
|
||||
}
|
||||
|
||||
devLoginRequest := jsonRequest(http.MethodPost, "/api/auth/login/dev", map[string]any{})
|
||||
devLoginRequest.AddCookie(devLogoutCookie)
|
||||
devLogin := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(devLogin, devLoginRequest)
|
||||
if devLogin.Code != http.StatusOK || !bytes.Contains(devLogin.Body.Bytes(), []byte(`"method":"dev"`)) {
|
||||
t.Fatalf("dev login response = %d %q, want dev principal", devLogin.Code, devLogin.Body.String())
|
||||
}
|
||||
clearedByDevLogin := cookieByName(t, devLogin.Result().Cookies(), devLogoutCookieName)
|
||||
if clearedByDevLogin.MaxAge != -1 {
|
||||
t.Fatalf("dev login cookie = %#v, want MaxAge -1", clearedByDevLogin)
|
||||
}
|
||||
|
||||
devTokenRequest := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
|
||||
devTokenRequest.AddCookie(devLogoutCookie)
|
||||
devTokenRequest.Header.Set("Authorization", "Bearer dev")
|
||||
devToken := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(devToken, devTokenRequest)
|
||||
if devToken.Code != http.StatusOK || !bytes.Contains(devToken.Body.Bytes(), []byte(`"method":"dev"`)) {
|
||||
t.Fatalf("dev token response = %d %q, want dev principal", devToken.Code, devToken.Body.String())
|
||||
}
|
||||
|
||||
loginRequest := jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
"password": "very secure password",
|
||||
})
|
||||
loginRequest.AddCookie(devLogoutCookie)
|
||||
login := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(login, loginRequest)
|
||||
if login.Code != http.StatusOK {
|
||||
t.Fatalf("login response = %d %q, want OK", login.Code, login.Body.String())
|
||||
}
|
||||
sessionCookie := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||
if sessionCookie.Value == "" {
|
||||
t.Fatalf("session cookie = %#v, want value", sessionCookie)
|
||||
}
|
||||
clearedDevLogoutCookie := cookieByName(t, login.Result().Cookies(), devLogoutCookieName)
|
||||
if clearedDevLogoutCookie.MaxAge != -1 {
|
||||
t.Fatalf("cleared dev logout cookie = %#v, want MaxAge -1", clearedDevLogoutCookie)
|
||||
}
|
||||
}
|
||||
|
||||
func cookieByName(t *testing.T, cookies []*http.Cookie, name string) *http.Cookie {
|
||||
t.Helper()
|
||||
for _, cookie := range cookies {
|
||||
if cookie.Name == name {
|
||||
return cookie
|
||||
}
|
||||
}
|
||||
t.Fatalf("response did not include cookie %q; cookies=%#v", name, cookies)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
|
||||
server := newAPITestServerWithSetup(t, false)
|
||||
|
||||
@@ -160,6 +265,123 @@ func TestInitialSetupWizardCreatesAdminAndPersistsSignupPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenCreationUsesDedicatedAccountPage(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
|
||||
login := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
"password": "very secure password",
|
||||
}))
|
||||
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
|
||||
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
|
||||
}
|
||||
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||
|
||||
accountRequest := httptest.NewRequest(http.MethodGet, "/account", nil)
|
||||
accountRequest.AddCookie(session)
|
||||
account := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(account, accountRequest)
|
||||
if account.Code != http.StatusOK {
|
||||
t.Fatalf("account status = %d, want %d", account.Code, http.StatusOK)
|
||||
}
|
||||
if bytes.Contains(account.Body.Bytes(), []byte("data-token-create")) {
|
||||
t.Fatal("account page should not render the token creation form")
|
||||
}
|
||||
if !bytes.Contains(account.Body.Bytes(), []byte(`href="/account/tokens/new"`)) {
|
||||
t.Fatal("account page should link to the token creation page")
|
||||
}
|
||||
|
||||
createRequest := httptest.NewRequest(http.MethodGet, "/account/tokens/new", nil)
|
||||
createRequest.AddCookie(session)
|
||||
create := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(create, createRequest)
|
||||
if create.Code != http.StatusOK || !bytes.Contains(create.Body.Bytes(), []byte("data-token-create")) {
|
||||
t.Fatalf("token creation page response = %d %q", create.Code, create.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUserAccessAndPasswordResetHTTPFlow(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
sender := &testEmailSender{}
|
||||
server.auth.SetEmailSender(sender)
|
||||
|
||||
login := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(login, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
"password": "very secure password",
|
||||
}))
|
||||
if login.Code != http.StatusOK || len(login.Result().Cookies()) == 0 {
|
||||
t.Fatalf("login response = %d %q, want session cookie", login.Code, login.Body.String())
|
||||
}
|
||||
session := cookieByName(t, login.Result().Cookies(), auth.SessionCookieName)
|
||||
|
||||
updateRequest := jsonRequest(http.MethodPatch, "/api/admin/auth-users", map[string]any{
|
||||
"users": []map[string]any{{
|
||||
"id": server.userID,
|
||||
"role": "admin",
|
||||
"disabled": false,
|
||||
}},
|
||||
})
|
||||
updateRequest.AddCookie(session)
|
||||
update := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(update, updateRequest)
|
||||
if update.Code != http.StatusOK {
|
||||
t.Fatalf("bulk user update response = %d %q", update.Code, update.Body.String())
|
||||
}
|
||||
|
||||
sendRequest := jsonRequest(http.MethodPost, "/api/admin/auth-users/"+url.PathEscape(server.userID)+"/password-reset", map[string]any{})
|
||||
sendRequest.AddCookie(session)
|
||||
send := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(send, sendRequest)
|
||||
if send.Code != http.StatusOK {
|
||||
t.Fatalf("send password reset response = %d %q", send.Code, send.Body.String())
|
||||
}
|
||||
token := passwordResetTokenFromEmail(t, sender.body)
|
||||
|
||||
page := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(page, httptest.NewRequest(http.MethodGet, "/password-reset?token="+url.QueryEscape(token), nil))
|
||||
if page.Code != http.StatusOK || !bytes.Contains(page.Body.Bytes(), []byte("data-password-reset")) {
|
||||
t.Fatalf("password reset page response = %d %q", page.Code, page.Body.String())
|
||||
}
|
||||
|
||||
reset := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(reset, jsonRequest(http.MethodPost, "/api/auth/password/reset", map[string]string{
|
||||
"token": token,
|
||||
"newPassword": "new very secure password",
|
||||
}))
|
||||
if reset.Code != http.StatusOK {
|
||||
t.Fatalf("password reset response = %d %q", reset.Code, reset.Body.String())
|
||||
}
|
||||
|
||||
newLogin := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(newLogin, jsonRequest(http.MethodPost, "/api/auth/login/password", map[string]string{
|
||||
"email": "editor@example.com",
|
||||
"password": "new very secure password",
|
||||
}))
|
||||
if newLogin.Code != http.StatusOK {
|
||||
t.Fatalf("new password login response = %d %q", newLogin.Code, newLogin.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func passwordResetTokenFromEmail(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if !strings.HasPrefix(line, "http") {
|
||||
continue
|
||||
}
|
||||
parsed, err := url.Parse(line)
|
||||
if err != nil {
|
||||
t.Fatalf("parse password reset URL: %v", err)
|
||||
}
|
||||
if token := parsed.Query().Get("token"); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
t.Fatalf("password reset body does not contain a link: %q", body)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -415,6 +637,74 @@ func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
ctx := context.Background()
|
||||
|
||||
nestedDir := filepath.Join(server.root, "content", "guide")
|
||||
if err := os.MkdirAll(nestedDir, 0o755); err != nil {
|
||||
t.Fatalf("create nested source dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(nestedDir, "setup.md"), []byte("# Setup\n\nNested"), 0o644); err != nil {
|
||||
t.Fatalf("write nested source fixture: %v", err)
|
||||
}
|
||||
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
|
||||
t.Fatalf("sync nested source fixture: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/documents/guide/setup.md/archive", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
|
||||
documents := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
|
||||
if documents.Code != http.StatusOK {
|
||||
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||
}
|
||||
if strings.Contains(documents.Body.String(), "guide/setup.md") {
|
||||
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsWrite)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil {
|
||||
t.Fatalf("write special source fixture: %v", err)
|
||||
}
|
||||
if _, err := server.docs.SyncSourceDir(ctx); err != nil {
|
||||
t.Fatalf("sync special source fixture: %v", err)
|
||||
}
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/documents/"+url.PathEscape("@dani.md")+"/archive", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
server.handler.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
|
||||
documents := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
|
||||
if documents.Code != http.StatusOK {
|
||||
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
|
||||
}
|
||||
if strings.Contains(documents.Body.String(), "@dani.md") {
|
||||
t.Fatalf("archived document still appears in active document list: %s", documents.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func multipartBody(t *testing.T, fieldName, filename string, content []byte) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user