fix(infra): gradle_run detecta android-sdk — issue 0076 #2
@@ -0,0 +1,24 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionCleanup elimina todas las sesiones con expires_at < ahora.
|
||||
// Retorna el numero de filas eliminadas.
|
||||
func SessionCleanup(db *sql.DB) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("session_cleanup: db nil")
|
||||
}
|
||||
res, err := db.Exec("DELETE FROM sessions WHERE expires_at < ?", time.Now().Unix())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("session_cleanup: delete: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("session_cleanup: rows_affected: %w", err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: session_cleanup
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SessionCleanup(db *sql.DB) (int64, error)"
|
||||
description: "Elimina todas las sesiones expiradas de la tabla sessions. Retorna el numero de filas eliminadas. Ejecutar periodicamente (cada N horas) para evitar acumulacion."
|
||||
tags: [session, auth, sqlite, cleanup, infra]
|
||||
uses_functions: []
|
||||
uses_types: []
|
||||
returns: []
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [database/sql, fmt, time]
|
||||
params:
|
||||
- name: db
|
||||
desc: "conexion SQL abierta a la BD de la app"
|
||||
output: "numero de sesiones eliminadas en esta ejecucion"
|
||||
tested: true
|
||||
tests: ["elimina solo sesiones expiradas, mantiene las activas"]
|
||||
test_file_path: "functions/infra/session_test.go"
|
||||
file_path: "functions/infra/session_cleanup.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
// En un ticker background de la app
|
||||
go func() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
n, err := SessionCleanup(db)
|
||||
if err != nil {
|
||||
log.Printf("cleanup error: %v", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("sesiones expiradas eliminadas: %d", n)
|
||||
}
|
||||
}()
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — DELETE en SQLite, lee `time.Now()`. DELETE con prepared statement evita SQL injection. Es seguro ejecutar en paralelo con SessionCreate/SessionValidate (SQLite tiene locking por default en WAL mode). Ejecutar cada 1h es suficiente para la mayoria de apps; si la tabla crece mucho considerar tambien un DELETE parcial con LIMIT.
|
||||
@@ -0,0 +1,83 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sessionEnsureTable crea la tabla sessions si no existe.
|
||||
// Columnas: token (PK), user_id, expires_at, created_at, metadata (JSON).
|
||||
func sessionEnsureTable(db *sql.DB) error {
|
||||
const stmt = `
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
metadata TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
`
|
||||
_, err := db.Exec(stmt)
|
||||
return err
|
||||
}
|
||||
|
||||
// SessionCreate genera un token aleatorio de 32 bytes (64 hex chars) e
|
||||
// inserta la sesion en la tabla sessions. Crea la tabla si no existe.
|
||||
// Retorna la Session completa lista para devolver al cliente.
|
||||
func SessionCreate(db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error) {
|
||||
var zero Session
|
||||
if db == nil {
|
||||
return zero, fmt.Errorf("session_create: db nil")
|
||||
}
|
||||
if userID == "" {
|
||||
return zero, fmt.Errorf("session_create: user_id vacio")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
return zero, fmt.Errorf("session_create: ttl debe ser positivo")
|
||||
}
|
||||
if err := sessionEnsureTable(db); err != nil {
|
||||
return zero, fmt.Errorf("session_create: crear tabla: %w", err)
|
||||
}
|
||||
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return zero, fmt.Errorf("session_create: rand: %w", err)
|
||||
}
|
||||
token := hex.EncodeToString(raw)
|
||||
|
||||
now := time.Now().Unix()
|
||||
exp := now + int64(ttl.Seconds())
|
||||
|
||||
var metaJSON []byte
|
||||
if metadata == nil {
|
||||
metaJSON = []byte("{}")
|
||||
} else {
|
||||
b, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("session_create: marshal metadata: %w", err)
|
||||
}
|
||||
metaJSON = b
|
||||
}
|
||||
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)",
|
||||
token, userID, exp, now, string(metaJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("session_create: insert: %w", err)
|
||||
}
|
||||
|
||||
return Session{
|
||||
Token: token,
|
||||
UserID: userID,
|
||||
ExpiresAt: exp,
|
||||
CreatedAt: now,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: session_create
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SessionCreate(db *sql.DB, userID string, ttl time.Duration, metadata map[string]any) (Session, error)"
|
||||
description: "Crea una sesion SQLite con token aleatorio de 32 bytes hex (256 bits de entropia). Crea la tabla sessions si no existe. Retorna la Session lista para devolver al cliente."
|
||||
tags: [session, auth, sqlite, token, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Session_go_infra]
|
||||
returns: [Session_go_infra]
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [crypto/rand, database/sql, encoding/hex, encoding/json, fmt, time]
|
||||
params:
|
||||
- name: db
|
||||
desc: "conexion SQL abierta a la BD de la app. No debe ser nil"
|
||||
- name: userID
|
||||
desc: "identificador del usuario al que pertenece la sesion. No vacio"
|
||||
- name: ttl
|
||||
desc: "tiempo de vida de la sesion (time.Duration). Debe ser positivo"
|
||||
- name: metadata
|
||||
desc: "datos libres a persistir junto a la sesion (role, email, ip, etc.). Puede ser nil"
|
||||
output: "Session con token opaco listo para devolver al cliente via header o cookie"
|
||||
tested: true
|
||||
tests: ["crea sesion y persiste en BD", "genera tokens distintos", "rechaza db nil", "rechaza user_id vacio"]
|
||||
test_file_path: "functions/infra/session_test.go"
|
||||
file_path: "functions/infra/session_create.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
db, _ := sql.Open("sqlite3", "app.db")
|
||||
session, err := SessionCreate(db, user.ID, 24*time.Hour, map[string]any{
|
||||
"email": user.Email,
|
||||
"role": "admin",
|
||||
"ip": r.RemoteAddr,
|
||||
})
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: 500, Code: "session_error", Message: err.Error()})
|
||||
return
|
||||
}
|
||||
HTTPJSONResponse(w, 200, map[string]string{"token": session.Token})
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — hace I/O en SQLite, usa entropia del OS con `crypto/rand`, y lee el tiempo con `time.Now()`. La tabla `sessions` se crea con `CREATE TABLE IF NOT EXISTS` (PK token, indices en user_id y expires_at) para que la primera llamada deje todo listo sin setup manual. Token es 32 bytes aleatorios codificados en hex = 64 chars, 256 bits de entropia. Session fixation mitigada: el token lo genera el servidor, no se acepta del cliente. Para invalidar una sesion: `DELETE FROM sessions WHERE token = ?`. Limpieza periodica de expiradas: SessionCleanup.
|
||||
@@ -0,0 +1,126 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func openSessionTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
db, err := sql.Open("sqlite3", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("sql.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestSessionCreate_PersistsSession(t *testing.T) {
|
||||
db := openSessionTestDB(t)
|
||||
s, err := SessionCreate(db, "user-1", time.Hour, map[string]any{"role": "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("SessionCreate: %v", err)
|
||||
}
|
||||
if s.Token == "" || len(s.Token) != 64 {
|
||||
t.Errorf("token de largo incorrecto: len=%d", len(s.Token))
|
||||
}
|
||||
if s.UserID != "user-1" {
|
||||
t.Errorf("UserID = %q", s.UserID)
|
||||
}
|
||||
if s.ExpiresAt <= s.CreatedAt {
|
||||
t.Errorf("ExpiresAt %d <= CreatedAt %d", s.ExpiresAt, s.CreatedAt)
|
||||
}
|
||||
|
||||
// Verificar persistencia
|
||||
var count int
|
||||
if err := db.QueryRow("SELECT COUNT(*) FROM sessions").Scan(&count); err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("esperaba 1 fila, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCreate_GeneratesDistinctTokens(t *testing.T) {
|
||||
db := openSessionTestDB(t)
|
||||
s1, _ := SessionCreate(db, "u", time.Hour, nil)
|
||||
s2, _ := SessionCreate(db, "u", time.Hour, nil)
|
||||
if s1.Token == s2.Token {
|
||||
t.Fatal("dos sesiones no pueden tener el mismo token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCreate_RejectsEmptyUserID(t *testing.T) {
|
||||
db := openSessionTestDB(t)
|
||||
if _, err := SessionCreate(db, "", time.Hour, nil); err == nil {
|
||||
t.Fatal("esperaba error con user_id vacio")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionValidate_ValidSession(t *testing.T) {
|
||||
db := openSessionTestDB(t)
|
||||
s, _ := SessionCreate(db, "user-7", time.Hour, map[string]any{"k": "v"})
|
||||
|
||||
got, err := SessionValidate(db, s.Token)
|
||||
if err != nil {
|
||||
t.Fatalf("SessionValidate: %v", err)
|
||||
}
|
||||
if got.UserID != "user-7" {
|
||||
t.Errorf("UserID = %q", got.UserID)
|
||||
}
|
||||
if got.Metadata["k"] != "v" {
|
||||
t.Errorf("metadata[k] = %v", got.Metadata["k"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionValidate_MissingToken(t *testing.T) {
|
||||
db := openSessionTestDB(t)
|
||||
_ = sessionEnsureTable(db)
|
||||
if _, err := SessionValidate(db, "nope"); err == nil {
|
||||
t.Fatal("esperaba error con token inexistente")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionValidate_ExpiredSession(t *testing.T) {
|
||||
db := openSessionTestDB(t)
|
||||
_ = sessionEnsureTable(db)
|
||||
// Insertar manualmente una sesion ya expirada
|
||||
past := time.Now().Unix() - 60
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)",
|
||||
"expired-tok", "u", past, past-100, "{}",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
if _, err := SessionValidate(db, "expired-tok"); err == nil {
|
||||
t.Fatal("esperaba error con sesion expirada")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCleanup_RemovesOnlyExpired(t *testing.T) {
|
||||
db := openSessionTestDB(t)
|
||||
active, _ := SessionCreate(db, "active-user", time.Hour, nil)
|
||||
_ = sessionEnsureTable(db)
|
||||
past := time.Now().Unix() - 60
|
||||
_, _ = db.Exec(
|
||||
"INSERT INTO sessions (token, user_id, expires_at, created_at, metadata) VALUES (?, ?, ?, ?, ?)",
|
||||
"expired", "u", past, past, "{}",
|
||||
)
|
||||
|
||||
n, err := SessionCleanup(db)
|
||||
if err != nil {
|
||||
t.Fatalf("SessionCleanup: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("esperaba 1 eliminada, got %d", n)
|
||||
}
|
||||
|
||||
// La activa sigue ahi
|
||||
if _, err := SessionValidate(db, active.Token); err != nil {
|
||||
t.Errorf("sesion activa no deberia haberse borrado: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionValidate busca el token en la tabla sessions, verifica que no este
|
||||
// expirado y retorna la Session con su metadata deserializada.
|
||||
// Error si el token no existe, esta expirado o la BD falla.
|
||||
func SessionValidate(db *sql.DB, token string) (Session, error) {
|
||||
var zero Session
|
||||
if db == nil {
|
||||
return zero, fmt.Errorf("session_validate: db nil")
|
||||
}
|
||||
if token == "" {
|
||||
return zero, fmt.Errorf("session_validate: token vacio")
|
||||
}
|
||||
|
||||
var (
|
||||
userID string
|
||||
expiresAt int64
|
||||
createdAt int64
|
||||
metaStr string
|
||||
)
|
||||
row := db.QueryRow(
|
||||
"SELECT user_id, expires_at, created_at, metadata FROM sessions WHERE token = ?",
|
||||
token,
|
||||
)
|
||||
if err := row.Scan(&userID, &expiresAt, &createdAt, &metaStr); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return zero, fmt.Errorf("session_validate: token no encontrado")
|
||||
}
|
||||
return zero, fmt.Errorf("session_validate: scan: %w", err)
|
||||
}
|
||||
|
||||
if time.Now().Unix() >= expiresAt {
|
||||
return zero, fmt.Errorf("session_validate: sesion expirada")
|
||||
}
|
||||
|
||||
var metadata map[string]any
|
||||
if metaStr != "" {
|
||||
if err := json.Unmarshal([]byte(metaStr), &metadata); err != nil {
|
||||
return zero, fmt.Errorf("session_validate: metadata malformada: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return Session{
|
||||
Token: token,
|
||||
UserID: userID,
|
||||
ExpiresAt: expiresAt,
|
||||
CreatedAt: createdAt,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: session_validate
|
||||
kind: function
|
||||
lang: go
|
||||
domain: infra
|
||||
version: "1.0.0"
|
||||
purity: impure
|
||||
signature: "func SessionValidate(db *sql.DB, token string) (Session, error)"
|
||||
description: "Busca un token en la tabla sessions, verifica que no este expirado y retorna la Session con metadata deserializada. Error si no existe o expirado."
|
||||
tags: [session, auth, sqlite, validate, infra]
|
||||
uses_functions: []
|
||||
uses_types: [Session_go_infra]
|
||||
returns: [Session_go_infra]
|
||||
returns_optional: false
|
||||
error_type: error_go_core
|
||||
imports: [database/sql, encoding/json, errors, fmt, time]
|
||||
params:
|
||||
- name: db
|
||||
desc: "conexion SQL abierta a la BD de la app"
|
||||
- name: token
|
||||
desc: "token opaco recibido del cliente (ej: header X-Session-Token o cookie)"
|
||||
output: "Session valida con user_id, metadata y timestamps. Error si token invalido o expirado"
|
||||
tested: true
|
||||
tests: ["valida token existente no expirado", "rechaza token inexistente", "rechaza token expirado"]
|
||||
test_file_path: "functions/infra/session_test.go"
|
||||
file_path: "functions/infra/session_validate.go"
|
||||
---
|
||||
|
||||
## Ejemplo
|
||||
|
||||
```go
|
||||
token := r.Header.Get("X-Session-Token")
|
||||
session, err := SessionValidate(db, token)
|
||||
if err != nil {
|
||||
HTTPErrorResponse(w, HTTPError{Status: 401, Code: "invalid_session", Message: "sesion invalida o expirada"})
|
||||
return
|
||||
}
|
||||
role, _ := session.Metadata["role"].(string)
|
||||
```
|
||||
|
||||
## Notas
|
||||
|
||||
Impura — I/O en SQLite, lee `time.Now()` para validar expiracion. Query con prepared statement (`?` placeholder) para evitar SQL injection. En respuestas HTTP al cliente no distinguir entre "token no existe" y "expirado" para no filtrar informacion. Si necesitas invalidar la sesion tras validar (ej: renovar token): `DELETE FROM sessions WHERE token = ?` en el mismo handler.
|
||||
Reference in New Issue
Block a user