package store_test import ( "context" "database/sql" "testing" "time" "github.com/wangjia/pangolin/server/internal/sessions" ) // seedUserDevice inserts a user + device so sessions FKs are satisfiable. func seedUserDevice(t *testing.T, db *sql.DB, userID, deviceID int64) { t.Helper() if _, err := db.Exec( `INSERT INTO users (id, uuid, email, pw_hash, dp_uuid, status) VALUES (?,?,?,?,?,?)`, userID, "u-uuid", "u@example.com", "h", "dp-uuid", "active"); err != nil { t.Fatalf("seed user: %v", err) } if _, err := db.Exec( `INSERT INTO devices (id, uuid, user_id, name, platform) VALUES (?,?,?,?,?)`, deviceID, "d-uuid", userID, "MacBook", "macos"); err != nil { t.Fatalf("seed device: %v", err) } } func TestSQLite_Sessions_CreateRotateRevoke(t *testing.T) { ctx := context.Background() db := openSQLite(t) seedUserDevice(t, db, 1, 10) ss := sessions.NewStore(db) if err := ss.Create(ctx, 1, 10, "jti-1", "1.2.3.4", "v1.0.10"); err != nil { t.Fatalf("Create: %v", err) } var jti, ver, ip string var revoked sql.NullTime if err := db.QueryRow( `SELECT refresh_jti, client_version, client_ip, revoked_at FROM sessions WHERE user_id=1`, ).Scan(&jti, &ver, &ip, &revoked); err != nil { t.Fatalf("read: %v", err) } if jti != "jti-1" || ver != "v1.0.10" || ip != "1.2.3.4" || revoked.Valid { t.Fatalf("bad session: jti=%s ver=%s ip=%s revoked=%v", jti, ver, ip, revoked.Valid) } // Rotate jti-1 → jti-2. if err := ss.Rotate(ctx, "jti-1", "jti-2"); err != nil { t.Fatalf("Rotate: %v", err) } var cnt int db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE refresh_jti='jti-2' AND revoked_at IS NULL`).Scan(&cnt) if cnt != 1 { t.Fatalf("rotate did not move jti: cnt=%d", cnt) } // Revoke jti-2. if err := ss.Revoke(ctx, "jti-2"); err != nil { t.Fatalf("Revoke: %v", err) } db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE refresh_jti='jti-2' AND revoked_at IS NOT NULL`).Scan(&cnt) if cnt != 1 { t.Fatalf("revoke did not set revoked_at") } } func TestSQLite_Sessions_RevokeByDevice(t *testing.T) { ctx := context.Background() db := openSQLite(t) seedUserDevice(t, db, 1, 10) ss := sessions.NewStore(db) for _, j := range []string{"a", "b", "c"} { if err := ss.Create(ctx, 1, 10, j, "", ""); err != nil { t.Fatalf("create %s: %v", j, err) } } // Pre-revoke one so it isn't returned/double-counted. if err := ss.Revoke(ctx, "c"); err != nil { t.Fatalf("pre-revoke: %v", err) } jtis, err := ss.RevokeByDevice(ctx, 1, 10) if err != nil { t.Fatalf("RevokeByDevice: %v", err) } if len(jtis) != 2 { t.Fatalf("expected 2 active jtis revoked, got %v", jtis) } var active int db.QueryRow(`SELECT COUNT(*) FROM sessions WHERE device_id=10 AND revoked_at IS NULL`).Scan(&active) if active != 0 { t.Fatalf("device still has %d active sessions", active) } } func TestSQLite_Sessions_LastLoginByDevice(t *testing.T) { ctx := context.Background() db := openSQLite(t) seedUserDevice(t, db, 1, 10) // second device db.Exec(`INSERT INTO devices (id, uuid, user_id, name, platform) VALUES (?,?,?,?,?)`, 20, "d-uuid-2", 1, "iPhone", "ios") older := time.Date(2026, 6, 1, 8, 0, 0, 0, time.UTC) newer := time.Date(2026, 6, 28, 9, 0, 0, 0, time.UTC) // device 10: two logins, newer should win. db.Exec(`INSERT INTO sessions (user_id, device_id, refresh_jti, created_at) VALUES (1,10,'o',?)`, older) db.Exec(`INSERT INTO sessions (user_id, device_id, refresh_jti, created_at) VALUES (1,10,'n',?)`, newer) // device 20: one login. db.Exec(`INSERT INTO sessions (user_id, device_id, refresh_jti, created_at) VALUES (1,20,'x',?)`, older) ss := sessions.NewStore(db) m, err := ss.LastLoginByDevice(ctx, 1) if err != nil { t.Fatalf("LastLoginByDevice: %v", err) } if !m[10].Equal(newer) { t.Fatalf("device 10 last login = %v, want %v", m[10], newer) } if !m[20].Equal(older) { t.Fatalf("device 20 last login = %v, want %v", m[20], older) } }