package storage_test import ( "database/sql" "errors" "os" "testing" "github.com/dariuszjaniak/warp/server/internal/storage" ) // migratedTestDB opens a driver, brings its schema up to date and empties // the userapi tables, which is what every user-store test needs before it // can touch one. func migratedTestDB(t *testing.T, driver string) storage.Database { t.Helper() db := openTestDB(t, driver) if err := db.Migrate(t.Context()); err != nil { t.Fatalf("Migrate: %v", err) } resetUserTables(t, driver) return db } // resetUserTables empties the userapi tables so that each test starts from a // known state. // // SQLite gets a fresh file per test and needs nothing. The PostgreSQL tests // all share the one database the environment points at, so without this the // second test to create "alice" would collide with the first — and would do // so only in CI, where PostgreSQL is available. func resetUserTables(t *testing.T, driver string) { t.Helper() if driver != storage.DriverPostgres { return } // The storage interface has no "delete an account", on purpose: // deactivation is Phase 9 and deletion may never exist. Clearing the // tables is a test concern, so the test does it directly. db, err := sql.Open("pgx", os.Getenv("WARP_TEST_POSTGRES_DSN")) if err != nil { t.Fatalf("open postgres to reset the user tables: %v", err) } defer func() { _ = db.Close() }() if _, err := db.ExecContext(t.Context(), `TRUNCATE user_account_data, user_refresh_tokens, user_access_tokens, user_devices, user_accounts`); err != nil { t.Fatalf("reset the user tables: %v", err) } } // newAccount is the minimum CreateAccount needs. func newAccount(localpart string) storage.NewAccount { return storage.NewAccount{ Localpart: localpart, PasswordHash: "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaA", CreatedAtMS: 1_700_000_000_000, } } func TestUserAccounts(t *testing.T) { for _, driver := range testDrivers { t.Run(driver, func(t *testing.T) { ctx := t.Context() users := migratedTestDB(t, driver).Users() if _, err := users.Account(ctx, "nobody"); !errors.Is(err, storage.ErrNoSuchUser) { t.Errorf("Account of an unknown user = %v, want ErrNoSuchUser", err) } acc, err := users.CreateAccount(ctx, newAccount("alice")) if err != nil { t.Fatalf("CreateAccount: %v", err) } if acc.Localpart != "alice" || acc.Admin || acc.Deactivated { t.Errorf("CreateAccount returned %+v", acc) } // A taken localpart must be reported as such, not as a generic // database failure: registration turns it into M_USER_IN_USE. if _, err := users.CreateAccount(ctx, newAccount("alice")); !errors.Is(err, storage.ErrUserExists) { t.Errorf("duplicate CreateAccount = %v, want ErrUserExists", err) } stored, err := users.Account(ctx, "alice") if err != nil { t.Fatalf("Account: %v", err) } if stored.CreatedAtMS != 1_700_000_000_000 { t.Errorf("created_at_ms = %d, want 1700000000000", stored.CreatedAtMS) } hash, err := users.PasswordHash(ctx, "alice") if err != nil || hash == "" { t.Fatalf("PasswordHash = %q, %v", hash, err) } if err := users.SetPasswordHash(ctx, "alice", "newhash"); err != nil { t.Fatalf("SetPasswordHash: %v", err) } if hash, _ = users.PasswordHash(ctx, "alice"); hash != "newhash" { t.Errorf("PasswordHash after update = %q", hash) } if err := users.SetAdmin(ctx, "alice", true); err != nil { t.Fatalf("SetAdmin: %v", err) } if err := users.SetDisplayName(ctx, "alice", "Alice"); err != nil { t.Fatalf("SetDisplayName: %v", err) } if err := users.SetAvatarURL(ctx, "alice", "mxc://warp.example/abc"); err != nil { t.Fatalf("SetAvatarURL: %v", err) } stored, err = users.Account(ctx, "alice") if err != nil { t.Fatalf("Account: %v", err) } if !stored.Admin { t.Error("the admin flag did not survive the round trip") } if stored.DisplayName != "Alice" || stored.AvatarURL != "mxc://warp.example/abc" { t.Errorf("profile = %+v", stored) } // Updating an absent account is ErrNoSuchUser, so that callers // do not have to look it up first. if err := users.SetDisplayName(ctx, "nobody", "x"); !errors.Is(err, storage.ErrNoSuchUser) { t.Errorf("SetDisplayName on an unknown user = %v, want ErrNoSuchUser", err) } }) } } func TestUserAccountListingAndStats(t *testing.T) { for _, driver := range testDrivers { t.Run(driver, func(t *testing.T) { ctx := t.Context() users := migratedTestDB(t, driver).Users() for _, localpart := range []string{"amy", "bob", "bobby", "zoe_x"} { if _, err := users.CreateAccount(ctx, newAccount(localpart)); err != nil { t.Fatalf("CreateAccount(%s): %v", localpart, err) } } if err := users.SetAdmin(ctx, "amy", true); err != nil { t.Fatalf("SetAdmin: %v", err) } if err := users.SetDisplayName(ctx, "zoe_x", "Zoe"); err != nil { t.Fatalf("SetDisplayName: %v", err) } all, total, err := users.ListAccounts(ctx, storage.AccountFilter{}) if err != nil { t.Fatalf("ListAccounts: %v", err) } if total != 4 || len(all) != 4 { t.Fatalf("ListAccounts returned %d of %d, want 4 of 4", len(all), total) } if all[0].Localpart != "amy" || all[3].Localpart != "zoe_x" { t.Errorf("accounts are not ordered by localpart: %v", all) } page, total, err := users.ListAccounts(ctx, storage.AccountFilter{Limit: 2, Offset: 1}) if err != nil { t.Fatalf("ListAccounts paged: %v", err) } if total != 4 || len(page) != 2 || page[0].Localpart != "bob" { t.Errorf("paged listing = %v (total %d)", page, total) } found, total, err := users.ListAccounts(ctx, storage.AccountFilter{Search: "BOB"}) if err != nil { t.Fatalf("ListAccounts search: %v", err) } if total != 2 || len(found) != 2 { t.Errorf("case-insensitive search for bob found %d, want 2", total) } // A display name is searchable too. if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "zoe"}); err != nil { t.Fatalf("ListAccounts: %v", err) } else if total != 1 { t.Errorf("searching display names found %d, want 1", total) } // "_" is a LIKE wildcard; escaping it is what keeps this from // matching every three-letter localpart. if _, total, err = users.ListAccounts(ctx, storage.AccountFilter{Search: "e_x"}); err != nil { t.Fatalf("ListAccounts: %v", err) } else if total != 1 { t.Errorf("searching for a literal underscore found %d, want 1", total) } stats, err := users.Stats(ctx) if err != nil { t.Fatalf("Stats: %v", err) } if stats.Users != 4 || stats.Admins != 1 || stats.Deactivated != 0 || stats.Devices != 0 { t.Errorf("Stats = %+v", stats) } }) } } func TestUserSessions(t *testing.T) { for _, driver := range testDrivers { t.Run(driver, func(t *testing.T) { ctx := t.Context() users := migratedTestDB(t, driver).Users() if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil { t.Fatalf("CreateAccount: %v", err) } session := storage.NewSession{ Localpart: "alice", DeviceID: "DEV1", DisplayName: "first login", CreatedAtMS: 1000, AccessTokenHash: "hash-a", LastSeenIP: "10.0.0.1", UserAgent: "warp-test", } device, err := users.CreateSession(ctx, session) if err != nil { t.Fatalf("CreateSession: %v", err) } if device.ID != "DEV1" || device.DisplayName != "first login" { t.Errorf("CreateSession returned %+v", device) } // A session for an unknown account must fail loudly rather than // leave a device with no owner. orphan := session orphan.Localpart, orphan.AccessTokenHash = "nobody", "hash-orphan" if _, err := users.CreateSession(ctx, orphan); !errors.Is(err, storage.ErrNoSuchUser) { t.Errorf("CreateSession for an unknown user = %v, want ErrNoSuchUser", err) } info, err := users.TokenInfo(ctx, "hash-a") if err != nil { t.Fatalf("TokenInfo: %v", err) } if info.Localpart != "alice" || info.DeviceID != "DEV1" || info.ExpiresAtMS != 0 { t.Errorf("TokenInfo = %+v", info) } if _, err := users.TokenInfo(ctx, "nope"); !errors.Is(err, storage.ErrNoSuchToken) { t.Errorf("TokenInfo of an unknown token = %v, want ErrNoSuchToken", err) } // Logging in again on the same device keeps the device and its // display name, and revokes the token it had. again := session again.AccessTokenHash = "hash-b" again.DisplayName = "ignored on an existing device" again.CreatedAtMS = 2000 device, err = users.CreateSession(ctx, again) if err != nil { t.Fatalf("CreateSession (same device): %v", err) } if device.DisplayName != "first login" { t.Errorf("display name = %q; initial_device_display_name applies to new devices only", device.DisplayName) } if device.CreatedAtMS != 1000 { t.Errorf("created_at_ms = %d; re-login must not reset it", device.CreatedAtMS) } if _, err := users.TokenInfo(ctx, "hash-a"); !errors.Is(err, storage.ErrNoSuchToken) { t.Error("the previous access token of a re-used device was not revoked") } if _, err := users.TokenInfo(ctx, "hash-b"); err != nil { t.Errorf("the new access token does not work: %v", err) } stored, err := users.Device(ctx, "alice", "DEV1") if err != nil { t.Fatalf("Device: %v", err) } if stored.LastSeenMS != 2000 || stored.LastSeenIP != "10.0.0.1" { t.Errorf("device = %+v", stored) } if _, err := users.Device(ctx, "alice", "GONE"); !errors.Is(err, storage.ErrNoSuchDevice) { t.Errorf("Device of an unknown device = %v, want ErrNoSuchDevice", err) } if err := users.TouchDevice(ctx, "alice", "DEV1", "10.0.0.2", "warp-test/2", 3000); err != nil { t.Fatalf("TouchDevice: %v", err) } if stored, err = users.Device(ctx, "alice", "DEV1"); err != nil { t.Fatal(err) } else if stored.LastSeenMS != 3000 || stored.LastSeenIP != "10.0.0.2" { t.Errorf("TouchDevice did not record the visit: %+v", stored) } if err := users.SetDeviceDisplayName(ctx, "alice", "DEV1", "renamed"); err != nil { t.Fatalf("SetDeviceDisplayName: %v", err) } if err := users.SetDeviceDisplayName(ctx, "alice", "GONE", "x"); !errors.Is(err, storage.ErrNoSuchDevice) { t.Errorf("renaming an unknown device = %v, want ErrNoSuchDevice", err) } second := storage.NewSession{ Localpart: "alice", DeviceID: "DEV2", CreatedAtMS: 4000, AccessTokenHash: "hash-c", } if _, err := users.CreateSession(ctx, second); err != nil { t.Fatalf("CreateSession (second device): %v", err) } devices, err := users.Devices(ctx, "alice") if err != nil { t.Fatalf("Devices: %v", err) } if len(devices) != 2 || devices[0].ID != "DEV1" || devices[1].ID != "DEV2" { t.Errorf("Devices = %v", devices) } deleted, err := users.DeleteDevices(ctx, "alice", []string{"DEV1", "NEVER-EXISTED"}) if err != nil { t.Fatalf("DeleteDevices: %v", err) } if deleted != 1 { t.Errorf("DeleteDevices reported %d, want 1", deleted) } if _, err := users.TokenInfo(ctx, "hash-b"); !errors.Is(err, storage.ErrNoSuchToken) { t.Error("deleting a device must revoke its access tokens") } deleted, err = users.DeleteAllDevices(ctx, "alice") if err != nil { t.Fatalf("DeleteAllDevices: %v", err) } if deleted != 1 { t.Errorf("DeleteAllDevices reported %d, want 1", deleted) } if _, err := users.TokenInfo(ctx, "hash-c"); !errors.Is(err, storage.ErrNoSuchToken) { t.Error("logout/all must revoke every token") } if devices, err = users.Devices(ctx, "alice"); err != nil { t.Fatal(err) } else if len(devices) != 0 { t.Errorf("devices survived DeleteAllDevices: %v", devices) } }) } } func TestUserRefreshTokens(t *testing.T) { for _, driver := range testDrivers { t.Run(driver, func(t *testing.T) { ctx := t.Context() users := migratedTestDB(t, driver).Users() if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil { t.Fatalf("CreateAccount: %v", err) } if _, err := users.CreateSession(ctx, storage.NewSession{ Localpart: "alice", DeviceID: "DEV1", CreatedAtMS: 1000, AccessTokenHash: "access-1", AccessTokenExpiresAtMS: 5000, RefreshTokenHash: "refresh-1", RefreshTokenExpiresAtMS: 0, }); err != nil { t.Fatalf("CreateSession: %v", err) } info, err := users.RefreshSession(ctx, storage.RefreshedSession{ RefreshTokenHash: "refresh-1", NewAccessTokenHash: "access-2", AccessTokenExpiresAtMS: 9000, NewRefreshTokenHash: "refresh-2", RefreshedAtMS: 4000, }) if err != nil { t.Fatalf("RefreshSession: %v", err) } if info.Localpart != "alice" || info.DeviceID != "DEV1" { t.Errorf("RefreshSession returned %+v", info) } // Both halves of the old pair are gone: a replayed refresh token // is the signal that one of the two copies is a thief. if _, err := users.RefreshSession(ctx, storage.RefreshedSession{ RefreshTokenHash: "refresh-1", NewAccessTokenHash: "access-3", NewRefreshTokenHash: "refresh-3", RefreshedAtMS: 4100, }); !errors.Is(err, storage.ErrNoSuchToken) { t.Errorf("replaying a spent refresh token = %v, want ErrNoSuchToken", err) } if _, err := users.TokenInfo(ctx, "access-1"); !errors.Is(err, storage.ErrNoSuchToken) { t.Error("refreshing must invalidate the previous access token") } if info, err = users.TokenInfo(ctx, "access-2"); err != nil { t.Fatalf("the refreshed access token does not work: %v", err) } else if info.ExpiresAtMS != 9000 { t.Errorf("expires_at_ms = %d, want 9000", info.ExpiresAtMS) } if _, err := users.RefreshSession(ctx, storage.RefreshedSession{ RefreshTokenHash: "nonexistent", NewAccessTokenHash: "access-4", RefreshedAtMS: 4200, }); !errors.Is(err, storage.ErrNoSuchToken) { t.Errorf("unknown refresh token = %v, want ErrNoSuchToken", err) } }) } } func TestUserRefreshTokenExpiry(t *testing.T) { for _, driver := range testDrivers { t.Run(driver, func(t *testing.T) { ctx := t.Context() users := migratedTestDB(t, driver).Users() if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil { t.Fatalf("CreateAccount: %v", err) } if _, err := users.CreateSession(ctx, storage.NewSession{ Localpart: "alice", DeviceID: "DEV1", CreatedAtMS: 1000, AccessTokenHash: "access-1", RefreshTokenHash: "refresh-1", RefreshTokenExpiresAtMS: 2000, }); err != nil { t.Fatalf("CreateSession: %v", err) } if _, err := users.RefreshSession(ctx, storage.RefreshedSession{ RefreshTokenHash: "refresh-1", NewAccessTokenHash: "access-2", RefreshedAtMS: 2001, }); !errors.Is(err, storage.ErrNoSuchToken) { t.Errorf("expired refresh token = %v, want ErrNoSuchToken", err) } }) } } func TestUserAccountData(t *testing.T) { for _, driver := range testDrivers { t.Run(driver, func(t *testing.T) { ctx := t.Context() users := migratedTestDB(t, driver).Users() if _, err := users.CreateAccount(ctx, newAccount("alice")); err != nil { t.Fatalf("CreateAccount: %v", err) } if _, err := users.AccountData(ctx, "alice", "", "m.push_rules"); !errors.Is(err, storage.ErrNoSuchAccountData) { t.Errorf("unset account data = %v, want ErrNoSuchAccountData", err) } global := []byte(`{"theme":"dark"}`) if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", global); err != nil { t.Fatalf("SetAccountData: %v", err) } got, err := users.AccountData(ctx, "alice", "", "im.warp.prefs") if err != nil { t.Fatalf("AccountData: %v", err) } if string(got) != string(global) { t.Errorf("account data came back as %s, want %s", got, global) } // Overwriting replaces rather than conflicting. if err := users.SetAccountData(ctx, "alice", "", "im.warp.prefs", []byte(`{"theme":"light"}`)); err != nil { t.Fatalf("SetAccountData (overwrite): %v", err) } if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil { t.Fatal(err) } else if string(got) != `{"theme":"light"}` { t.Errorf("overwritten account data = %s", got) } // Room account data is a separate key space from global data of // the same type. if err := users.SetAccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs", []byte(`{"muted":true}`)); err != nil { t.Fatalf("SetAccountData (room): %v", err) } if got, err = users.AccountData(ctx, "alice", "!room:warp.example", "im.warp.prefs"); err != nil { t.Fatal(err) } else if string(got) != `{"muted":true}` { t.Errorf("room account data = %s", got) } if got, err = users.AccountData(ctx, "alice", "", "im.warp.prefs"); err != nil { t.Fatal(err) } else if string(got) != `{"theme":"light"}` { t.Errorf("room account data overwrote the global value: %s", got) } }) } }