One conditional UPDATE writes it at most once per five minutes per user. A failed write is logged and never fails authentication, and no user payload carries the column.
232 lines
8.4 KiB
Go
232 lines
8.4 KiB
Go
package user
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-user-plugin/classes"
|
|
"git.golem15.com/golem15/sm-user-plugin/controllers"
|
|
"git.golem15.com/golem15/sm-user-plugin/models"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// TestLastSeen covers users.last_seen (D-17, D-29): login and refresh stamp
|
|
// it at most once per five minutes, a failing write never fails them, and it
|
|
// never reaches a user API payload.
|
|
func TestLastSeen(t *testing.T) {
|
|
app, gdb := sessionApp(t, "0s")
|
|
user := insertUser(t, gdb, "last-seen@example.test", "correct-horse")
|
|
const credentials = `{"email":"last-seen@example.test","password":"correct-horse"}`
|
|
|
|
lastSeen := func() *time.Time {
|
|
t.Helper()
|
|
var row struct{ LastSeen *time.Time }
|
|
if err := gdb.Raw(`SELECT last_seen FROM users WHERE id = ?`, user.ID).Scan(&row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return row.LastSeen
|
|
}
|
|
setLastSeen := func(age time.Duration) time.Time {
|
|
t.Helper()
|
|
at := time.Now().Add(-age).Truncate(time.Microsecond)
|
|
if err := gdb.Exec(`UPDATE users SET last_seen = ? WHERE id = ?`, at, user.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return at
|
|
}
|
|
login := func() (string, map[string]any, string) {
|
|
t.Helper()
|
|
rec := postJSON(t, controllers.Login(app), "/_user/api/v1/login", credentials)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("login %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Token string `json:"token"`
|
|
User map[string]any `json:"user"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Token == "" || len(body.User) == 0 {
|
|
t.Fatalf("login body %s", rec.Body.String())
|
|
}
|
|
return body.Token, body.User, rec.Body.String()
|
|
}
|
|
refresh := func(token string) (string, string) {
|
|
t.Helper()
|
|
rec := bearer(t, controllers.Refresh(app), http.MethodPost, "/_user/api/v1/refresh", token)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("refresh %d %s", rec.Code, rec.Body.String())
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
next, _ := body["token"].(string)
|
|
if next == "" || len(body) != 1 {
|
|
t.Fatalf("refresh body %s", rec.Body.String())
|
|
}
|
|
return next, rec.Body.String()
|
|
}
|
|
updatedAt := func() time.Time {
|
|
t.Helper()
|
|
var at time.Time
|
|
if err := gdb.Raw(`SELECT updated_at FROM users WHERE id = ?`, user.ID).Scan(&at).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return at
|
|
}
|
|
|
|
if lastSeen() != nil {
|
|
t.Fatal("a new user already has last_seen")
|
|
}
|
|
stampedBefore := updatedAt()
|
|
|
|
// A login stamps it.
|
|
started := time.Now().Add(-time.Second)
|
|
token, payload, raw := login()
|
|
first := lastSeen()
|
|
if first == nil || first.Before(started) || first.After(time.Now().Add(time.Second)) {
|
|
t.Fatalf("last_seen after the first login = %v", first)
|
|
}
|
|
if !updatedAt().Equal(stampedBefore) {
|
|
t.Fatal("stamping last_seen touched updated_at")
|
|
}
|
|
// A second login and a refresh inside five minutes leave it alone.
|
|
token2, _, _ := login()
|
|
if got := lastSeen(); got == nil || !got.Equal(*first) {
|
|
t.Fatalf("a second login inside five minutes moved last_seen: %v -> %v", first, got)
|
|
}
|
|
token, _ = refresh(token)
|
|
if got := lastSeen(); got == nil || !got.Equal(*first) {
|
|
t.Fatalf("a refresh inside five minutes moved last_seen: %v -> %v", first, got)
|
|
}
|
|
|
|
// Six minutes later a refresh stamps it again, and so does a login.
|
|
old := setLastSeen(6 * time.Minute)
|
|
_, refreshRaw := refresh(token)
|
|
afterRefresh := lastSeen()
|
|
if afterRefresh == nil || !afterRefresh.After(old.Add(5*time.Minute)) {
|
|
t.Fatalf("a refresh after six minutes did not stamp last_seen: %v (was %v)", afterRefresh, old)
|
|
}
|
|
old = setLastSeen(6 * time.Minute)
|
|
login()
|
|
if got := lastSeen(); got == nil || !got.After(old.Add(5*time.Minute)) {
|
|
t.Fatalf("a login after six minutes did not stamp last_seen: %v", got)
|
|
}
|
|
// Four minutes is inside the window.
|
|
recent := setLastSeen(4 * time.Minute)
|
|
refresh(token2)
|
|
if got := lastSeen(); got == nil || !got.Equal(recent) {
|
|
t.Fatalf("a refresh after four minutes moved last_seen: %v -> %v", recent, got)
|
|
}
|
|
|
|
// A failing write never fails authentication: with a trigger that
|
|
// refuses every last_seen update, login and refresh still answer 200
|
|
// with the same body shape.
|
|
// last_seen is ten minutes old, so both handlers attempt the write.
|
|
blocked := setLastSeen(10 * time.Minute)
|
|
drop := func() {
|
|
gdb.Exec(`DROP TRIGGER IF EXISTS test_refuse_last_seen ON users`)
|
|
gdb.Exec(`DROP FUNCTION IF EXISTS test_refuse_last_seen()`)
|
|
}
|
|
t.Cleanup(drop)
|
|
for _, stmt := range []string{
|
|
`CREATE OR REPLACE FUNCTION test_refuse_last_seen() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'last_seen cannot be written in this test'; END $$ LANGUAGE plpgsql`,
|
|
`CREATE TRIGGER test_refuse_last_seen BEFORE UPDATE OF last_seen ON users FOR EACH ROW EXECUTE FUNCTION test_refuse_last_seen()`,
|
|
} {
|
|
if err := gdb.Exec(stmt).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := classes.TouchLastSeen(t.Context(), gdb, user.ID); err == nil {
|
|
t.Fatal("the test trigger does not refuse the write, or TouchLastSeen swallowed the failure")
|
|
}
|
|
failedToken, failedPayload, _ := login()
|
|
_, failedRefreshRaw := refresh(failedToken)
|
|
if got := lastSeen(); got == nil || !got.Equal(blocked) {
|
|
t.Fatalf("last_seen changed although the write is refused: %v -> %v", blocked, got)
|
|
}
|
|
if len(failedPayload) != len(payload) {
|
|
t.Fatalf("the login payload changed shape when the write failed: %v vs %v", failedPayload, payload)
|
|
}
|
|
for key := range payload {
|
|
if _, ok := failedPayload[key]; !ok {
|
|
t.Fatalf("the login payload lost %s when the write failed", key)
|
|
}
|
|
}
|
|
if !strings.HasPrefix(failedRefreshRaw, `{"token":"`) || !strings.HasPrefix(refreshRaw, `{"token":"`) {
|
|
t.Fatalf("refresh bodies: %s / %s", refreshRaw, failedRefreshRaw)
|
|
}
|
|
drop()
|
|
|
|
// No user payload carries the admin columns; permissions stays null and
|
|
// groups an empty list.
|
|
current, _, _ := login()
|
|
fetch := bearer(t, controllers.Fetch(app), http.MethodGet, "/_user/api/v1/fetch", current)
|
|
if fetch.Code != http.StatusOK {
|
|
t.Fatalf("fetch %d %s", fetch.Code, fetch.Body.String())
|
|
}
|
|
var fetched struct {
|
|
User map[string]any `json:"user"`
|
|
}
|
|
if err := json.Unmarshal(fetch.Body.Bytes(), &fetched); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for name, body := range map[string]string{"login": raw, "fetch": fetch.Body.String(), "refresh": refreshRaw} {
|
|
for _, key := range []string{"last_seen", "created_at", "updated_at", "LastSeen", "CreatedAt", "UpdatedAt", "Permissions"} {
|
|
if strings.Contains(body, `"`+key+`"`) {
|
|
t.Fatalf("the %s body carries %s: %s", name, key, body)
|
|
}
|
|
}
|
|
}
|
|
for name, got := range map[string]map[string]any{"login": payload, "fetch": fetched.User} {
|
|
if value, ok := got["permissions"]; !ok || value != nil {
|
|
t.Fatalf("%s payload permissions = %v (%v)", name, value, ok)
|
|
}
|
|
if groups, ok := got["groups"].([]any); !ok || len(groups) != 0 {
|
|
t.Fatalf("%s payload groups = %v", name, got["groups"])
|
|
}
|
|
}
|
|
|
|
// The model itself never serializes them either.
|
|
now := time.Now()
|
|
marshalled, err := json.Marshal(models.User{
|
|
ID: 1, Email: "marshal@example.test", LastSeen: &now, CreatedAt: now, UpdatedAt: now,
|
|
Permissions: models.PermissionSet{"blog.post": 1}, Groups: []models.UserGroup{{Name: "Admins"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
lower := strings.ToLower(string(marshalled))
|
|
for _, key := range []string{"last_seen", "lastseen", "permissions", "created_at", "createdat", "updated_at", "updatedat", "groups", "blog.post", "admins"} {
|
|
if strings.Contains(lower, key) {
|
|
t.Fatalf("a marshalled user carries %s: %s", key, marshalled)
|
|
}
|
|
}
|
|
if strings.Contains(string(marshalled), `"Password"`) {
|
|
t.Fatalf("a marshalled user carries the password: %s", marshalled)
|
|
}
|
|
|
|
// The helper itself: a deactivated user is stamped, an unknown id and a
|
|
// zero id are no error, a missing handle is.
|
|
if err := gdb.Exec(`UPDATE users SET last_seen = NULL, deleted_at = NOW() WHERE id = ?`, user.ID).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := classes.TouchLastSeen(t.Context(), gdb, user.ID); err != nil || lastSeen() == nil {
|
|
t.Fatalf("a deactivated user was not stamped: %v", err)
|
|
}
|
|
if err := classes.TouchLastSeen(t.Context(), gdb, 987654321); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := classes.TouchLastSeen(t.Context(), gdb, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := classes.TouchLastSeen(t.Context(), (*gorm.DB)(nil), user.ID); err == nil {
|
|
t.Fatal("TouchLastSeen without a handle")
|
|
}
|
|
}
|