CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Implements PLAN.md Phase 1. A client can now register, log in, manage its devices and set its profile; an operator can create accounts and flip the runtime switches. There are still no rooms and no real /sync. userapi: accounts, argon2id passwords in PHC format, devices, access and refresh tokens stored as SHA-256 hashes, account data and the admin flag. Its storage interface is storage.UserStore, implemented once in sqlstore with `?` placeholders; the postgres driver supplies a rebind function and unique-violation recognition rather than keeping a second copy of thirty statements. Timestamps in these tables are int64 milliseconds, the unit the Client-Server API uses on the wire. Client-Server API: register (user-interactive auth with m.login.dummy and optional registration tokens), register/available, token validity, login with m.login.password, logout, logout/all, refresh, account/whoami, capabilities, devices/* with password-gated deletion, profile/*, global and per-room account data. GET /sync is a deliberate placeholder: it answers a valid token with an empty but well-formed response and refuses a revoked one. Phase 1 has no rooms, so that is the whole truth, and it is what makes session revocation observable. Phase 4 replaces it with the syncapi. Auth middleware resolves a bearer token (header or the legacy query parameter) to a device and user, answering an expired token with the soft logout the spec asks for. Rate limiting is a token bucket per endpoint class keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms. adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix token, GET/PUT /federation and /registration driving internal/settings and reporting a config-pinned switch as a 409, GET/POST /users, GET /stats. warp create-account [--admin] creates the first account straight in the database, so a new install with registration off has a way in. Complement is wired up for the federation=off profile: an image meeting its contract, an entrypoint that configures Warp entirely from WARP_* variables, and a runner that builds, clones and executes the target set (TestRegistration, TestLogin, TestDeviceManagement). It has not been run yet -- there is no container runtime on the development machine -- so the three tests' assertions were read from the Complement sources and pinned as httptest tests instead, and CI gained a job that runs the real thing. Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern, meant for the built binary, also matched the directory server/cmd/warp and silently excluded the entire command package from the repository. The binary patterns are now anchored to the repository root, and cmd/warp is committed for the first time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
495 lines
17 KiB
Go
495 lines
17 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|