A course registration and management web application built with ASP.NET Core MVC.
Students browse and apply to courses; administrators manage courses, instructors and applications.
The system separates two roles:
- Students register an account, browse the course catalogue, and apply to a course.
- Administrators sign in to a management area where they create and edit courses, manage the instructor roster, and review incoming applications.
Data is persisted with Entity Framework Core against SQLite, with schema changes tracked through migrations.
The application worked. Adding tests to it was supposed to be a formality — writing down behaviour that already held. Several of the first tests failed, and the reasons were not cosmetic.
| Finding | What it meant |
|---|---|
AdminController had no [Authorize] |
Every management action answered anonymous requests. POST /Admin/KursSil deleted a course — and cascaded to its applications — with no session at all. |
| Administrator credentials were string literals | if (kullaniciAdi == "admin" && sifre == "1234") sat in the login action. The working password shipped with the source. |
| Passwords were stored and compared in clear text | o.Sifre == sifre. A read of the database was a read of every account's password. |
| Cancelling an application never checked ownership | The endpoint took an application id and deleted it. Any signed-in student could cancel anyone else's place by incrementing an id. |
| Capacity was a read-then-write race | Applications were counted, compared against Kontenjan, and only then inserted. |
| Identity was resolved by display name | o.AdSoyad == User.Identity.Name. Two students with the same name resolved to whichever row the database returned first. |
The capacity bug is the one worth measuring. Reproducing the original logic under 15 concurrent applications to a course with capacity 5:
old logic (count → compare → insert): 15 enrolled ← 3× over capacity
current logic (conditional UPDATE): 5 enrolled
That measurement is what Es_zamanli_basvurular_kontenjani_asamaz locks in.
Kurs carries a KayitliSayisi counter, and enrolment claims a seat and checks capacity in
a single statement:
UPDATE Kurslar SET KayitliSayisi = KayitliSayisi + 1
WHERE Id = @id AND KayitliSayisi < KontenjanZero rows affected means the course was full. There is no window between the check and the
write for a second request to slip through. The insert that follows runs in the same
transaction, and a unique index on (KursId, OgrenciId) settles duplicate applications at
the database rather than in application code — the application-level check is a nicety for
the error message, not the thing being relied on.
| Concern | Approach |
|---|---|
| Password storage | PBKDF2-HMAC-SHA256, 210 000 iterations, 128-bit random salt per password. The iteration count is embedded in the stored hash so it can be raised without invalidating existing rows. Comparison is fixed-time. |
| Administrator account | A row in Yoneticiler, seeded from configuration. With no password configured, development generates a random one and logs it once; production refuses to create the account. |
| Authorization | [Authorize(Roles = "Admin")] at the class level on the management area, verified by integration tests that drive the real HTTP pipeline. |
| CSRF | AutoValidateAntiforgeryTokenAttribute registered globally, so a new POST action is protected by default rather than when someone remembers the attribute. |
| Ownership | Cancellation compares the application's owner against the caller's NameIdentifier claim. |
| Open redirect | returnUrl is followed only when Url.IsLocalUrl accepts it. |
| User enumeration | Login does not distinguish "no such user" from "wrong password", and runs a hash verification even when the user does not exist so the two paths take comparable time. |
| Dependencies | CI fails the build on any package with a known advisory, including transitive ones. |
KursKayitSistemi/
├── Controllers/ Thin: authorize, bind, delegate, choose a view
├── Services/ Rules with real invariants — enrolment, accounts, password hashing
├── Models/ Entities + AppDbContext (indexes, delete behaviour, constraints)
├── ViewModels/ Binding targets, kept separate from entities to prevent over-posting
├── Views/ Razor views per controller + shared layout
└── Migrations/ EF Core schema history
Not every controller got a service. Enrolment did, because it has invariants that must hold
under concurrency and is worth testing in isolation. Instructor and course CRUD stayed in
AdminController: wrapping a single Add and SaveChanges in a service class would add a
layer without adding a rule.
| Entity | Purpose |
|---|---|
Kurs |
A course, with Kontenjan (capacity) and the maintained KayitliSayisi |
Egitmen |
An instructor. Deleting one is Restrict, not Cascade — courses must be dealt with first |
Ogrenci |
A registered student; stores SifreHash, never a password |
Basvuru |
A student's application. (KursId, OgrenciId) is unique |
Yonetici |
An administrator account |
Requirements: .NET SDK 10.0+
git clone https://github.com/kutsibalci/Course-Registration-System.git
cd Course-Registration-System
dotnet run --project KursKayitSistemiMigrations are applied at startup, so there is no separate database update step. On the
first run the console prints a generated administrator password:
warn: DatabaseSeeder[0]
SeedAdmin:Sifre tanimli degil. Gelistirme icin rastgele bir yonetici sifresi uretildi.
Kullanici adi : admin
Sifre : 7Qk2mZ0pXbNc
To choose it yourself instead:
dotnet user-secrets set "SeedAdmin:Sifre" "kendi-sifreniz" --project KursKayitSistemiUpgrading an existing database: the migration drops the clear-text
Sifrecolumn. A hash cannot be recovered from it, so student accounts must be created again.
dotnet test62 tests, no external dependencies — each one creates its own SQLite file and runs the real
migrations against it. The in-memory provider is deliberately not used: it supports neither
transactions nor ExecuteUpdate, which are exactly the mechanisms the capacity fix depends on.
| Suite | Covers |
|---|---|
PasswordHashServiceTests |
Round-trip, per-password salting, embedded iteration count, malformed stored hashes |
AccountServiceTests |
Registration, duplicate detection, authentication, role assignment |
EnrollmentServiceTests |
Capacity, duplicates, ownership, seat release, 15-way concurrent enrolment |
AuthorizationIntegrationTests |
The real HTTP pipeline: anonymous, student and administrator against every management route; antiforgery; the retired hard-coded password |
SchemaTests |
Model/migration drift, unique indexes, cascade and restrict behaviour |
Built as a learning project to work through the full ASP.NET Core MVC stack. The most useful part was not the first version — it was discovering, by writing tests against something that appeared to work, how much of "working" was untested assumption.
Built by Hüseyin Kutsi Balcı · MIT licensed