Commit da29d648 authored by serverKhansia's avatar serverKhansia

fix(auth): repair PostgreSQL column types and preserve login server errors

parent 7d0e284d
"""Restore typed columns imported as text without changing stored identifiers."""
from django.apps import apps
from django.core.management.base import BaseCommand, CommandError
from django.db import connection, transaction
class Command(BaseCommand):
help = "Validate text columns against Django types; use --apply after backing up."
def add_arguments(self, parser):
parser.add_argument("--apply", action="store_true")
def handle(self, *args, **options):
if connection.vendor != "postgresql":
raise CommandError("This repair is only for PostgreSQL.")
quote = connection.ops.quote_name
changes = {}
with connection.cursor() as cursor:
cursor.execute(
"SELECT table_name, column_name, udt_name "
"FROM information_schema.columns WHERE table_schema = current_schema()"
)
actual = {(t, c): kind for t, c, kind in cursor.fetchall()}
for model in apps.get_models(include_auto_created=True):
for field in model._meta.local_fields:
key = (model._meta.db_table, field.column)
target = field.db_type(connection)
if actual.get(key) != "text" or not target:
continue
if target in {
"bigint", "integer", "smallint", "boolean", "date",
"timestamp with time zone", "time", "double precision",
"uuid", "jsonb",
} or target.startswith("numeric("):
changes[key] = (target, field.null)
# Validate every cast first. Any invalid value aborts the entire repair.
with transaction.atomic(), connection.cursor() as cursor:
cursor.execute("SET LOCAL lock_timeout = '5s'")
for (table, column), (target, nullable) in changes.items():
name = quote(column)
value = f"NULLIF({name}, '')" if nullable else name
cursor.execute(f"SELECT COUNT(({value})::{target}) FROM {quote(table)}")
self.stdout.write(f"Validated {len(changes)} column conversions.")
if options["apply"]:
tables = {}
for (table, column), (target, nullable) in changes.items():
name = quote(column)
value = f"NULLIF({name}, '')" if nullable else name
tables.setdefault(table, []).append(
f"ALTER COLUMN {name} TYPE {target} USING ({value})::{target}"
)
for table, alterations in tables.items():
cursor.execute(
f"ALTER TABLE {quote(table)} " + ", ".join(alterations)
)
self.stdout.write(self.style.SUCCESS("Column types repaired atomically."))
else:
self.stdout.write("Validation only; no schema changes. Back up before --apply.")
# Perbaikan login, 28 Oktober 2026
## Penyebab
- HTTP 503 publik: `imi-api.service` berhenti normal pukul 12:55 WIB;
Gunicorn pada port 8010 tidak tersedia. Log tidak mengidentifikasi pemicu stop.
- HTTP 500 sebelum layanan berhenti berhasil direproduksi pada PostgreSQL:
`accounts_member.user_id` bertipe text, sementara ID user bertipe bigint.
Query relasi gagal dengan `operator does not exist: text = integer`.
- Pemeriksaan model menemukan 389 kolom text yang seharusnya berupa angka,
boolean, tanggal/waktu, JSON, atau UUID.
## Tindakan
1. Backup custom-format PostgreSQL diverifikasi dengan `pg_restore --list`:
`/tmp/imi-login-repair-backup/20261028-133038.dump` (192516 byte, mode 0600).
Backup berisi data pengguna; jangan commit atau publikasikan. Pindahkan ke
penyimpanan backup privat yang persisten sebelum pembersihan `/tmp`.
2. Command `repair_postgres_column_types` memvalidasi cast terhadap model Django.
Default hanya validasi; `--apply` menerapkan perubahan dalam satu transaksi,
dengan lock timeout 5 detik. Nilai kosong pada kolom nullable menjadi NULL.
Command hanya memperbaiki tipe, bukan membuat ulang constraint/index/sequence.
3. Simulasi konversi dan login dijalankan dalam transaksi yang di-rollback.
4. Setelah simulasi lulus, 389 konversi diterapkan dan `imi-api.service` dinyalakan.
Pemeriksaan ulang menemukan 0 konversi tersisa.
5. Mobile mempertahankan status HTTP 5xx saat login, termasuk respons HTML dari
proxy, alih-alih melaporkannya sebagai status 0/kesalahan koneksi.
## Verifikasi
- Django system check dan pip check lulus.
- 24 tes akun lulus pada SQLite in-memory dengan OTP development aktif.
- Simulasi PostgreSQL: login admin/member dan profil 200; katalog event/partner,
notifikasi dan tiket member 200; password salah 401. Tiket untuk admin tanpa
keanggotaan menghasilkan 403 sesuai permission.
- Uji HTTPS publik dengan verifikasi sertifikat: GET login 405; POST login
admin/member dan profil 200; event, partner, notifikasi, tiket member 200;
password salah 401. Token tidak dicetak atau disimpan oleh skrip pengujian.
- Tes regresi mobile ditambahkan pada `mobile/test/login_server_error_test.dart`.
Flutter/Dart SDK tidak tersedia pada server ini, sehingga tes mobile dan build
APK belum dijalankan. Perubahan mobile memerlukan build/distribusi berikutnya.
Migrasi seed SPKLU yang belum diterapkan tidak diperlukan untuk perbaikan login
dan tidak dijalankan. Konfigurasi endpoint mobile serta file web-admin yang sudah
berubah sebelum pekerjaan ini tidak ditimpa.
......@@ -101,6 +101,14 @@ class ApiService {
}),
);
if (res.statusCode >= 500) {
return ApiResponse(
success: false,
message: 'Server backend mengalami gangguan (HTTP ${res.statusCode}). Silakan coba lagi nanti.',
statusCode: res.statusCode,
);
}
final data = _decodeJsonSafely(res);
if (res.statusCode == 200) {
if (autoSaveTokens &&
......
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:imi_jabar_app/core/services/api_service.dart';
import 'package:imi_jabar_app/core/services/storage_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
await StorageService.init();
await StorageService.clearAuth();
});
tearDown(ApiService.resetClient);
for (final status in [500, 503]) {
test('Login preserves HTTP $status for an HTML server error', () async {
ApiService.client = MockClient((_) async => http.Response(
'<html>Service unavailable</html>', status,
headers: {'content-type': 'text/html'},
));
final result = await ApiService.login(
identifier: 'test', password: 'test',
);
expect(result.success, isFalse);
expect(result.statusCode, status);
expect(result.message, contains('HTTP $status'));
expect(StorageService.getAccessToken(), isNull);
});
}
test('Login distinguishes a network failure from an HTTP response', () async {
ApiService.client = MockClient((_) async {
throw http.ClientException('Connection refused');
});
final result = await ApiService.login(identifier: 'test', password: 'test');
expect(result.success, isFalse);
expect(result.statusCode, 0);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment